File: Projects\DotNetAppHostProjectTests.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 Aspire.Cli.Commands;
using Aspire.Cli.Layout;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Hosting.Utils;
using Aspire.Shared;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System.Text.Json;
 
namespace Aspire.Cli.Tests.Projects;
 
public class DotNetAppHostProjectTests(ITestOutputHelper outputHelper) : IDisposable
{
    private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.CreateForCli(outputHelper);
    private readonly List<ServiceProvider> _serviceProviders = [];
 
    public DotNetAppHostProjectTests UseFakeRepoRoot()
    {
        // Tests that build their own fake bundle layout under a temp directory must opt out
        // of the in-repo aspire-managed discovery; otherwise the repo's real built artifact
        // shadows the fake bundle path the test pre-stamped into the layout.
        DotNetAppHostProject.RepoLocalManagedPathProviderOverride = () => null;
        return this;
    }
 
    public void Dispose()
    {
        DotNetAppHostProject.RepoLocalManagedPathProviderOverride = null;
 
        foreach (var serviceProvider in _serviceProviders)
        {
            serviceProvider.Dispose();
        }
 
        _workspace.Dispose();
        GC.SuppressFinalize(this);
    }
 
    [Theory]
    [InlineData(true, false)]
    [InlineData(false, true)]
    public void ShouldKillEntireProcessTreeOnCancel_KillsOnlyTargetProcessOnWindows(bool isWindows, bool expected)
    {
        var result = DotNetAppHostProject.ShouldKillEntireProcessTreeOnCancel(isWindows);
 
        Assert.Equal(expected, result);
    }
 
    [Fact]
    public async Task ValidateAppHostAsync_OrdinaryMarkerFreeProject_SkipsEvaluation()
    {
        // An ordinary library project with no AppHost markers is rejected by the cheap heuristic
        // before the expensive MSBuild evaluation runs, so the resolver is never consulted.
        var ordinaryProject = CreateOrdinaryProject("Library.csproj");
        var runner = new TestDotNetCliRunner();
        var resolver = new TestAppHostInfoResolver();
        var project = CreateDotNetAppHostProject(runner, appHostInfoResolver: resolver);
 
        var validation = await project.ValidateAppHostAsync(ordinaryProject, CancellationToken.None);
 
        Assert.False(validation.IsValid);
        Assert.False(validation.IsPossiblyUnbuildable);
        Assert.Equal(0, resolver.CallCount);
    }
 
    [Fact]
    public async Task ValidateAppHostAsync_AppHostConventionalProject_EvaluatesViaResolver()
    {
        // An AppHost-conventional project is not confidently rejected, so it flows into MSBuild
        // evaluation via the resolver, which confirms it is an Aspire host.
        var appHostFile = CreateProjectAppHost();
        var runner = new TestDotNetCliRunner();
        var resolver = new TestAppHostInfoResolver
        {
            GetAppHostInfoAsyncCallback = (_, _) => Task.FromResult(new AppHostProjectInfo(
                ExitCode: 0,
                IsAspireHost: true,
                AspireHostingVersion: "13.0.0",
                IsUsingCliBundle: false,
                UserSecretsId: null,
                RunCommand: null,
                TargetPath: null,
                RunWorkingDirectory: null,
                RunArguments: null,
                TargetFramework: "net10.0",
                TargetFrameworks: null))
        };
        var project = CreateDotNetAppHostProject(runner, appHostInfoResolver: resolver);
 
        var validation = await project.ValidateAppHostAsync(appHostFile, CancellationToken.None);
 
        Assert.True(validation.IsValid);
        Assert.Equal("13.0.0", validation.AspireHostingVersion);
        Assert.Equal(1, resolver.CallCount);
    }
 
    [Fact]
    public async Task ValidationAndVersionInspectionDoNotAcquireCliBundle()
    {
        var appHostFile = CreateProjectAppHost();
        _ = CreateCliBundle(out var layout);
        var bundleAcquisitionRequested = false;
        var bundleService = new TestBundleService(isBundle: true)
        {
            Layout = layout,
            EnsureExtractedAndAcquireLayoutAsyncCallback = _ =>
            {
                bundleAcquisitionRequested = true;
                return Task.CompletedTask;
            }
        };
        var resolver = new TestAppHostInfoResolver
        {
            GetAppHostInfoAsyncCallback = (_, _) => Task.FromResult(new AppHostProjectInfo(
                ExitCode: 0,
                IsAspireHost: true,
                AspireHostingVersion: "13.0.0",
                IsUsingCliBundle: true,
                UserSecretsId: null,
                RunCommand: null,
                TargetPath: null,
                RunWorkingDirectory: null,
                RunArguments: null,
                TargetFramework: "net10.0",
                TargetFrameworks: null))
        };
        var project = CreateDotNetAppHostProject(
            new TestDotNetCliRunner(),
            layout,
            resolver,
            options => options.BundleServiceFactory = _ => bundleService);
 
        var validation = await project.ValidateAppHostAsync(appHostFile, CancellationToken.None);
        var version = await project.GetAspireHostingVersionAsync(appHostFile, CancellationToken.None);
 
        Assert.True(validation.IsValid);
        Assert.Equal("13.0.0", version);
        Assert.False(bundleAcquisitionRequested);
    }
 
    [Fact]
    public async Task ValidateAppHostAsync_EvaluatesCleanlyButNotAspireHost_RejectsWithoutPossiblyUnbuildable()
    {
        // A project can pass the cheap name heuristic without being an Aspire host: e.g. a
        // Microsoft.NET.Sdk.Web project that merely sits next to an apphost.cs. MSBuild then evaluates it
        // cleanly (exit code 0) and authoritatively reports IsAspireHost == false. That is a definitive "no",
        // so it must be rejected quietly rather than surfaced as a spurious possibly-unbuildable AppHost.
        var appHostFile = CreateProjectAppHost();
        var runner = new TestDotNetCliRunner();
        var resolver = new TestAppHostInfoResolver
        {
            GetAppHostInfoAsyncCallback = (_, _) => Task.FromResult(new AppHostProjectInfo(
                ExitCode: 0,
                IsAspireHost: false,
                AspireHostingVersion: null,
                IsUsingCliBundle: false,
                UserSecretsId: null,
                RunCommand: null,
                TargetPath: null,
                RunWorkingDirectory: null,
                RunArguments: null,
                TargetFramework: "net10.0",
                TargetFrameworks: null))
        };
        var project = CreateDotNetAppHostProject(runner, appHostInfoResolver: resolver);
 
        var validation = await project.ValidateAppHostAsync(appHostFile, CancellationToken.None);
 
        Assert.False(validation.IsValid);
        Assert.False(validation.IsPossiblyUnbuildable);
        Assert.Equal(1, resolver.CallCount);
    }
 
    [Fact]
    public async Task ValidateAppHostAsync_LikelyAppHostFailsEvaluation_MarksPossiblyUnbuildable()
    {
        // A likely AppHost that MSBuild cannot evaluate (non-zero exit) may still be a real AppHost that
        // currently fails to build, so it is kept as a candidate and surfaced as possibly-unbuildable rather
        // than silently discarded.
        var appHostFile = CreateProjectAppHost();
        var runner = new TestDotNetCliRunner();
        var resolver = new TestAppHostInfoResolver
        {
            GetAppHostInfoAsyncCallback = (_, _) => Task.FromResult(new AppHostProjectInfo(
                ExitCode: 1,
                IsAspireHost: false,
                AspireHostingVersion: null,
                IsUsingCliBundle: false,
                UserSecretsId: null,
                RunCommand: null,
                TargetPath: null,
                RunWorkingDirectory: null,
                RunArguments: null,
                TargetFramework: null,
                TargetFrameworks: null))
        };
        var project = CreateDotNetAppHostProject(runner, appHostInfoResolver: resolver);
 
        var validation = await project.ValidateAppHostAsync(appHostFile, CancellationToken.None);
 
        Assert.False(validation.IsValid);
        Assert.True(validation.IsPossiblyUnbuildable);
        Assert.Equal(1, resolver.CallCount);
    }
 
    [Fact]
    public void ConfigureSingleFileRunEnvironment_DefaultsToDevelopmentForRun()
    {
        var appHostFile = CreateSingleFileAppHost();
        var env = new Dictionary<string, string>();
 
        DotNetAppHostProject.ConfigureSingleFileRunEnvironment(
            appHostFile,
            env,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("Development", env[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.False(env.ContainsKey(KnownAspNetCoreConfigNames.Environment));
        Assert.Equal("https://localhost:17193;http://localhost:15069", env[KnownAspNetCoreConfigNames.Urls]);
    }
 
    [Fact]
    public void ConfigureSingleFilePublishEnvironment_DefaultsToProductionForPublish()
    {
        var appHostFile = CreateSingleFileAppHost();
        var env = new Dictionary<string, string>();
 
        DotNetAppHostProject.ConfigureSingleFilePublishEnvironment(
            appHostFile,
            env,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("Production", env[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.False(env.ContainsKey(KnownAspNetCoreConfigNames.Environment));
        Assert.Equal("https://localhost:17193;http://localhost:15069", env[KnownAspNetCoreConfigNames.Urls]);
    }
 
    [Fact]
    public void ConfigureSingleFilePublishEnvironment_EnvironmentArgumentTakesPrecedenceOverDefaultEnvironment()
    {
        var appHostFile = CreateSingleFileAppHost();
        var env = new Dictionary<string, string>();
 
        DotNetAppHostProject.ConfigureSingleFilePublishEnvironment(
            appHostFile,
            env,
            inheritedEnvironmentVariables: new Dictionary<string, string?>(),
            args: ["--environment", "Staging"]);
 
        Assert.Equal("Staging", env[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.False(env.ContainsKey(KnownAspNetCoreConfigNames.Environment));
        Assert.Equal("https://localhost:17193;http://localhost:15069", env[KnownAspNetCoreConfigNames.Urls]);
    }
 
    [Fact]
    public void ConfigureSingleFilePublishEnvironment_StripsLaunchProfileEnvironmentButKeepsEndpoints()
    {
        var appHostFile = CreateSingleFileAppHost();
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "apphost.run.json"), """
            {
              "profiles": {
                "https": {
                  "applicationUrl": "https://localhost:19000;http://localhost:15000",
                  "environmentVariables": {
                    "ASPNETCORE_ENVIRONMENT": "Development",
                    "DOTNET_ENVIRONMENT": "Development",
                    "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000",
                    "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000"
                  }
                }
              }
            }
            """);
 
        var env = new Dictionary<string, string>();
 
        DotNetAppHostProject.ConfigureSingleFilePublishEnvironment(
            appHostFile,
            env,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("Production", env[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.False(env.ContainsKey(KnownAspNetCoreConfigNames.Environment));
        Assert.Equal("https://localhost:19000;http://localhost:15000", env[KnownAspNetCoreConfigNames.Urls]);
        Assert.Equal("https://localhost:21000", env["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]);
        Assert.Equal("https://localhost:22000", env["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]);
    }
 
    [Fact]
    public void ConfigureSingleFilePublishEnvironment_InheritedAspireEnvironmentOverridesDefaultEnvironment()
    {
        var appHostFile = CreateSingleFileAppHost();
        var env = new Dictionary<string, string>();
 
        DotNetAppHostProject.ConfigureSingleFilePublishEnvironment(
            appHostFile,
            env,
            inheritedEnvironmentVariables: new Dictionary<string, string?>
            {
                [AppHostEnvironmentDefaults.AspireEnvironmentVariableName] = "Staging"
            });
 
        Assert.Equal("Staging", env[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.False(env.ContainsKey(KnownAspNetCoreConfigNames.Environment));
    }
 
    [Fact]
    public async Task RunAsync_SingleFileAppHostWithoutRunJsonPassesDevelopmentEnvironmentToRunner()
    {
        var appHostFile = CreateSingleFileAppHost();
        var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
        var runner = new TestDotNetCliRunner();
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, _, options, _) =>
        {
            Assert.Equal(appHostFile.FullName, projectFile.FullName);
            Assert.False(watch);
            Assert.True(noBuild);
            Assert.False(noRestore);
            Assert.False(options.NoLaunchProfile);
            Assert.Equal("Development", env![KnownAspNetCoreConfigNames.DotNetEnvironment]);
            Assert.False(env.ContainsKey(KnownAspNetCoreConfigNames.Environment));
            Assert.Equal("https://localhost:17193;http://localhost:15069", env[KnownAspNetCoreConfigNames.Urls]);
            Assert.Equal(expectedWorkloadId, env[KnownConfigNames.DcpWorkloadId]);
            return Task.FromResult(0);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = true,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_SingleFileAppHostUsesEnvironmentArgumentWhenProvided()
    {
        var appHostFile = CreateSingleFileAppHost();
        var runner = new TestDotNetCliRunner();
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, _, options, _) =>
        {
            Assert.Equal(appHostFile.FullName, projectFile.FullName);
            Assert.False(watch);
            Assert.True(noBuild);
            Assert.False(noRestore);
            Assert.False(options.NoLaunchProfile);
            Assert.Equal(["--environment", "Staging"], args);
            Assert.Equal("Staging", env![KnownAspNetCoreConfigNames.DotNetEnvironment]);
            Assert.False(env.ContainsKey(KnownAspNetCoreConfigNames.Environment));
            return Task.FromResult(0);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = true,
            NoRestore = false,
            UnmatchedTokens = ["--environment", "Staging"],
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task FindAndStopRunningInstanceAsync_CleansUpDeadPidSocketAndReturnsNoRunningInstance()
    {
        var appHostFile = CreateSingleFileAppHost();
        var runner = new TestDotNetCliRunner();
        var project = CreateDotNetAppHostProject(runner);
        var socketPath = CreateMatchingSocketFile(appHostFile.FullName, int.MaxValue - 1);
 
        var result = await project.FindAndStopRunningInstanceAsync(
            appHostFile,
            _workspace.WorkspaceRoot,
            CancellationToken.None);
 
        Assert.Equal(RunningInstanceResult.NoRunningInstance, result);
        Assert.False(File.Exists(socketPath));
    }
 
    [Fact]
    public async Task FindAndStopRunningInstanceAsync_KeepsLivePidSocketAndReportsStopFailureWhenConnectionFails()
    {
        var appHostFile = CreateSingleFileAppHost();
        var runner = new TestDotNetCliRunner();
        var project = CreateDotNetAppHostProject(runner);
        var socketPath = CreateMatchingSocketFile(appHostFile.FullName, Environment.ProcessId);
 
        var result = await project.FindAndStopRunningInstanceAsync(
            appHostFile,
            _workspace.WorkspaceRoot,
            CancellationToken.None);
 
        Assert.Equal(RunningInstanceResult.StopFailed, result);
        Assert.True(File.Exists(socketPath));
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostUsingCliBundlePassesBundleEnvironmentToRunner()
    {
        UseFakeRepoRoot();
        var appHostFile = CreateProjectAppHost();
        var bundleRoot = CreateCliBundle(out var layout);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, properties, _, _) =>
            {
                Assert.Contains("AspireUseCliBundle", properties);
                return (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true",
                        "AspireHostingSDKVersion": "{{VersionHelper.GetDefaultTemplateVersion()}}",
                        "AspireUseCliBundle": "true"
                      },
                      "Items": {}
                    }
                    """));
            }
        };
        var project = CreateDotNetAppHostProject(runner, layout);
 
        runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, _, options, _) =>
        {
            Assert.Equal(appHostFile.FullName, projectFile.FullName);
            Assert.False(watch);
            Assert.True(noBuild);
            Assert.False(noRestore);
            Assert.Equal(bundleRoot.FullName, env!["AspireCliBundlePath"]);
            Assert.Equal(Path.Combine(bundleRoot.FullName, BundleDiscovery.DcpDirectoryName), env![BundleDiscovery.DcpPathEnvVar]);
            Assert.Equal(
                Path.Combine(bundleRoot.FullName, BundleDiscovery.ManagedDirectoryName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)),
                env[BundleDiscovery.DashboardPathEnvVar]);
            // Terminal host env vars are always injected when the bundle layout is available
            // — see the comment in ConfigureCliBundleEnvironmentAsync. For CliBundle AppHosts
            // they sit alongside the DCP/Dashboard vars; both point at aspire-managed.
            Assert.Equal(
                Path.Combine(bundleRoot.FullName, BundleDiscovery.ManagedDirectoryName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)),
                env[BundleDiscovery.TerminalHostPathEnvVar]);
            Assert.Equal("terminalhost", env[BundleDiscovery.TerminalHostInvocationArgsEnvVar]);
            return Task.FromResult(0);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostUsingCliBundlePreservesCallerProvidedRuntimePaths()
    {
        UseFakeRepoRoot();
        var appHostFile = CreateProjectAppHost();
        _ = CreateCliBundle(out var layout);
        var customDcpDirectory = Directory.CreateDirectory(Path.Combine(_workspace.WorkspaceRoot.FullName, "custom-dcp"));
        File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(customDcpDirectory.FullName), "");
        var customDashboardPath = Path.Combine(
            _workspace.WorkspaceRoot.FullName,
            BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName));
        File.WriteAllText(customDashboardPath, "");
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
                (0, JsonSerializer.SerializeToDocument(new
                {
                    Properties = new
                    {
                        IsAspireHost = "true",
                        AspireHostingSDKVersion = VersionHelper.GetDefaultTemplateVersion(),
                        AspireUseCliBundle = "true",
                    },
                    Items = new { }
                }))
        };
        var project = CreateDotNetAppHostProject(runner, layout);
 
        runner.RunAsyncCallback = (_, _, _, _, _, env, _, _, _) =>
        {
            Assert.Equal(customDcpDirectory.FullName, env![BundleDiscovery.DcpPathEnvVar]);
            Assert.Equal(customDashboardPath, env[BundleDiscovery.DashboardPathEnvVar]);
            return Task.FromResult(0);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>
            {
                [BundleDiscovery.DcpPathEnvVar] = customDcpDirectory.FullName,
                [BundleDiscovery.DashboardPathEnvVar] = customDashboardPath,
            }
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostUsingCliBundlePreservesInheritedRuntimePaths()
    {
        UseFakeRepoRoot();
        var appHostFile = CreateProjectAppHost();
        var bundleRoot = CreateCliBundle(out var layout);
        var inheritedDcpDirectory = Directory.CreateDirectory(Path.Combine(_workspace.WorkspaceRoot.FullName, "inherited-dcp")).FullName;
        File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(inheritedDcpDirectory), "");
        var inheritedDashboardPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "inherited-dashboard");
        File.WriteAllText(inheritedDashboardPath, "");
        var environment = new TestEnvironment(new Dictionary<string, string?>
        {
            [BundleDiscovery.DcpPathEnvVar] = inheritedDcpDirectory,
            [BundleDiscovery.DashboardPathEnvVar] = inheritedDashboardPath,
        });
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
                (0, JsonSerializer.SerializeToDocument(new
                {
                    Properties = new
                    {
                        IsAspireHost = "true",
                        AspireHostingSDKVersion = VersionHelper.GetDefaultTemplateVersion(),
                        AspireUseCliBundle = "true",
                    },
                    Items = new { }
                }))
        };
        var project = CreateDotNetAppHostProject(runner, layout, environment: environment);
 
        runner.RunAsyncCallback = (_, _, _, _, _, env, _, _, _) =>
        {
            // Inherited values remain in the process environment. They must not be shadowed
            // by values in the child-process overlay built from the current CLI layout.
            Assert.False(env!.ContainsKey(BundleDiscovery.DcpPathEnvVar));
            Assert.False(env.ContainsKey(BundleDiscovery.DashboardPathEnvVar));
            Assert.Equal(bundleRoot.FullName, env["AspireCliBundlePath"]);
            return Task.FromResult(0);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task RunAsync_ProjectAppHostUsingCliBundleReplacesUnusableRuntimePaths(
        bool useExplicitOverlay,
        bool useEmptyValues)
    {
        UseFakeRepoRoot();
        var appHostFile = CreateProjectAppHost();
        _ = CreateCliBundle(out var layout);
        var unusableDcpDirectory = useEmptyValues
            ? ""
            : Path.Combine(_workspace.WorkspaceRoot.FullName, "missing-dcp");
        var unusableDashboardPath = useEmptyValues
            ? ""
            : Path.Combine(_workspace.WorkspaceRoot.FullName, "missing-dashboard");
        var environmentVariables = new Dictionary<string, string>
        {
            [BundleDiscovery.DcpPathEnvVar] = unusableDcpDirectory,
            [BundleDiscovery.DashboardPathEnvVar] = unusableDashboardPath,
        };
        var inheritedEnvironment = useExplicitOverlay
            ? new TestEnvironment()
            : new TestEnvironment(environmentVariables.ToDictionary(pair => pair.Key, pair => (string?)pair.Value));
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
                (0, JsonSerializer.SerializeToDocument(new
                {
                    Properties = new
                    {
                        IsAspireHost = "true",
                        AspireHostingSDKVersion = VersionHelper.GetDefaultTemplateVersion(),
                        AspireUseCliBundle = "true",
                    },
                    Items = new { }
                }))
        };
        var project = CreateDotNetAppHostProject(runner, layout, environment: inheritedEnvironment);
 
        runner.RunAsyncCallback = (_, _, _, _, _, env, _, _, _) =>
        {
            Assert.Equal(layout.GetDcpPath(), env![BundleDiscovery.DcpPathEnvVar]);
            Assert.Equal(layout.GetManagedPath(), env[BundleDiscovery.DashboardPathEnvVar]);
            return Task.FromResult(0);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = useExplicitOverlay ? environmentVariables : new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostUsingCliBundleDoesNotInjectInspectionRuntimePaths()
    {
        UseFakeRepoRoot();
        var appHostFile = CreateProjectAppHost();
        var runnerCalled = false;
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
                (0, JsonSerializer.SerializeToDocument(new
                {
                    Properties = new
                    {
                        IsAspireHost = "true",
                        AspireHostingSDKVersion = VersionHelper.GetDefaultTemplateVersion(),
                        AspireUseCliBundle = "true",
                        DcpDir = "inspection-dcp",
                        AspireDashboardPath = "inspection-dashboard",
                    },
                    Items = new { }
                })),
            RunAsyncCallback = (_, _, _, _, _, env, _, _, _) =>
            {
                runnerCalled = true;
                Assert.False(env!.ContainsKey(BundleDiscovery.DcpPathEnvVar));
                Assert.False(env.ContainsKey(BundleDiscovery.DashboardPathEnvVar));
                return Task.FromResult(42);
            }
        };
        var project = CreateDotNetAppHostProject(runner);
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(42, exitCode);
        Assert.True(runnerCalled);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostUsingCliBundleDoesNotInjectMissingLayoutComponents()
    {
        UseFakeRepoRoot();
        var appHostFile = CreateProjectAppHost();
        _ = CreateCliBundle(out var layout);
        File.Delete(BundleDiscovery.GetDcpExecutablePath(layout.GetDcpPath()!));
        File.Delete(layout.GetManagedPath()!);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
                (0, JsonSerializer.SerializeToDocument(new
                {
                    Properties = new
                    {
                        IsAspireHost = "true",
                        AspireHostingSDKVersion = VersionHelper.GetDefaultTemplateVersion(),
                        AspireUseCliBundle = "true",
                    },
                    Items = new { }
                })),
            RunAsyncCallback = (_, _, _, _, _, env, _, _, _) =>
            {
                Assert.False(env!.ContainsKey(BundleDiscovery.DcpPathEnvVar));
                Assert.False(env.ContainsKey(BundleDiscovery.DashboardPathEnvVar));
                Assert.False(env.ContainsKey(BundleDiscovery.TerminalHostPathEnvVar));
                return Task.FromResult(0);
            }
        };
        var project = CreateDotNetAppHostProject(runner, layout);
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task PublishAsync_ProjectAppHostUsingCliBundleDoesNotAcquireOrInjectBundle(bool noBuild)
    {
        UseFakeRepoRoot();
        var appHostFile = CreateProjectAppHost();
        _ = CreateCliBundle(out var layout);
        var bundleAcquisitionRequested = false;
        var bundleService = new TestBundleService(isBundle: true)
        {
            Layout = layout,
            EnsureExtractedAndAcquireLayoutAsyncCallback = _ =>
            {
                bundleAcquisitionRequested = true;
                return Task.CompletedTask;
            }
        };
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
                (0, JsonSerializer.SerializeToDocument(new
                {
                    Properties = new
                    {
                        IsAspireHost = "true",
                        AspireHostingSDKVersion = VersionHelper.GetDefaultTemplateVersion(),
                        AspireUseCliBundle = "true",
                    },
                    Items = new { }
                })),
            RunAsyncCallback = (_, _, runnerNoBuild, _, _, env, _, options, _) =>
            {
                Assert.True(runnerNoBuild);
                Assert.True(options.NoLaunchProfile);
                Assert.False(env!.ContainsKey("AspireCliBundlePath"));
                Assert.False(env.ContainsKey(BundleDiscovery.DcpPathEnvVar));
                Assert.False(env.ContainsKey(BundleDiscovery.DashboardPathEnvVar));
                Assert.False(env.ContainsKey(BundleDiscovery.TerminalHostPathEnvVar));
                return Task.FromResult(0);
            }
        };
        var project = CreateDotNetAppHostProject(
            runner,
            layout,
            configureServices: options => options.BundleServiceFactory = _ => bundleService);
 
        var exitCode = await project.PublishAsync(new PublishContext
        {
            AppHostFile = appHostFile,
            NoBuild = noBuild,
            WorkingDirectory = _workspace.WorkspaceRoot,
            Arguments = ["--operation", "publish"],
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
        Assert.False(bundleAcquisitionRequested);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostNotUsingCliBundleStillReceivesTerminalHostEnvironment()
    {
        UseFakeRepoRoot();
        // AppHosts created by `aspire new` default to per-RID NuGets (AspireUseCliBundle != true).
        // Today no per-RID NuGet stamps the terminal host metadata path, so without env-var
        // injection WithTerminal() resources fail at run time with <unresolved>. The CLI ships
        // aspire-managed in its bundle and that binary exposes the `terminalhost` subcommand,
        // so injecting ASPIRE_TERMINAL_HOST_PATH unconditionally lights up WithTerminal() for
        // per-RID-NuGet AppHosts launched via `aspire run`.
        var appHostFile = CreateProjectAppHost();
        var bundleRoot = CreateCliBundle(out var layout);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                return (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true",
                        "AspireHostingSDKVersion": "{{VersionHelper.GetDefaultTemplateVersion()}}",
                        "AspireUseCliBundle": "false"
                      },
                      "Items": {}
                    }
                    """));
            }
        };
        var project = CreateDotNetAppHostProject(runner, layout);
 
        runner.RunAsyncCallback = (projectFile, _, _, _, _, env, _, _, _) =>
        {
            Assert.Equal(appHostFile.FullName, projectFile.FullName);
 
            // DCP/Dashboard env vars must NOT be injected for non-CliBundle AppHosts —
            // they would clobber the per-RID NuGet metadata path the AppHost was built against.
            Assert.False(env!.ContainsKey(BundleDiscovery.DcpPathEnvVar));
            Assert.False(env.ContainsKey(BundleDiscovery.DashboardPathEnvVar));
 
            // Terminal host env vars must be injected even though AspireUseCliBundle=false.
            Assert.Equal(
                Path.Combine(bundleRoot.FullName, BundleDiscovery.ManagedDirectoryName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)),
                env[BundleDiscovery.TerminalHostPathEnvVar]);
            Assert.Equal("terminalhost", env[BundleDiscovery.TerminalHostInvocationArgsEnvVar]);
            return Task.FromResult(0);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostNotUsingCliBundleUsesRepoLocalManagedWhenAvailable()
    {
        // When running `dotnet run --project src/Aspire.Cli` from inside the Aspire repo,
        // the just-built aspire-managed under artifacts/ should be preferred over the bundle
        // layout aspire-managed. The bundle layout points at the user's installed CLI cache
        // (e.g. ~/.aspire/bundle/) whose aspire-managed predates the `terminalhost`
        // subcommand and fails the AppHost launch.
        var appHostFile = CreateProjectAppHost();
        var bundleRoot = CreateCliBundle(out var layout);
        var repoLocalManaged = Path.Combine(_workspace.WorkspaceRoot.FullName, "repo-local-aspire-managed");
        File.WriteAllText(repoLocalManaged, "fake");
        DotNetAppHostProject.RepoLocalManagedPathProviderOverride = () => repoLocalManaged;
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                return (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true",
                        "AspireHostingSDKVersion": "{{VersionHelper.GetDefaultTemplateVersion()}}",
                        "AspireUseCliBundle": "false"
                      },
                      "Items": {}
                    }
                    """));
            }
        };
        var project = CreateDotNetAppHostProject(runner, layout);
 
        runner.RunAsyncCallback = (_, _, _, _, _, env, _, _, _) =>
        {
            // Repo-local managed path wins over the bundle layout path.
            Assert.Equal(repoLocalManaged, env![BundleDiscovery.TerminalHostPathEnvVar]);
            Assert.NotEqual(
                Path.Combine(bundleRoot.FullName, BundleDiscovery.ManagedDirectoryName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)),
                env[BundleDiscovery.TerminalHostPathEnvVar]);
            // Args still synthesized — repo-local aspire-managed is the same dispatcher binary.
            Assert.Equal("terminalhost", env[BundleDiscovery.TerminalHostInvocationArgsEnvVar]);
            return Task.FromResult(0);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_PreservesExplicitTerminalHostEnvironmentVariables()
    {
        UseFakeRepoRoot();
        // Users can side-load a custom terminal host binary by setting ASPIRE_TERMINAL_HOST_PATH
        // themselves. The CLI must not overwrite either the path OR the invocation args in that
        // case — a custom binary may not understand the "terminalhost" dispatcher arg that
        // aspire-managed uses, so the path/args must be preserved together as a pair.
        var appHostFile = CreateProjectAppHost();
        _ = CreateCliBundle(out var layout);
        var customTerminalHost = Path.Combine(_workspace.WorkspaceRoot.FullName, "my-custom-terminal-host");
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                return (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true",
                        "AspireHostingSDKVersion": "{{VersionHelper.GetDefaultTemplateVersion()}}",
                        "AspireUseCliBundle": "false"
                      },
                      "Items": {}
                    }
                    """));
            }
        };
        var project = CreateDotNetAppHostProject(runner, layout);
 
        runner.RunAsyncCallback = (_, _, _, _, _, env, _, _, _) =>
        {
            // User-provided path is preserved verbatim.
            Assert.Equal(customTerminalHost, env![BundleDiscovery.TerminalHostPathEnvVar]);
            // And the CLI must NOT synthesize invocation args for a binary it didn't choose —
            // those args are bundle-binary-specific (today: "terminalhost" for aspire-managed).
            Assert.False(env.ContainsKey(BundleDiscovery.TerminalHostInvocationArgsEnvVar));
            return Task.FromResult(0);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>
            {
                [BundleDiscovery.TerminalHostPathEnvVar] = customTerminalHost
            }
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostUsesDirectCommandLaunchAndAppliesLaunchSettings()
    {
        var appHostFile = CreateProjectAppHost();
        var targetPath = CreateBuiltAppHostAssembly("AppHost.dll");
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        var runWorkingDirectory = Directory.CreateDirectory(Path.Combine(_workspace.WorkspaceRoot.FullName, "run-cwd"));
        var appHostCommandJson = JsonSerializer.Serialize(appHostCommand.FullName);
        var targetPathJson = JsonSerializer.Serialize(targetPath.FullName);
        var runWorkingDirectoryJson = JsonSerializer.Serialize(runWorkingDirectory.FullName);
        Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "Properties", "launchSettings.json"), """
            {
              "profiles": {
                "IIS Express": {
                  "commandName": "IISExpress",
                  "applicationUrl": "https://should-not-be-used"
                },
                "http": {
                  "commandName": "Project",
                  "applicationUrl": "http://localhost:15000",
                  "commandLineArgs": "--from-profile \"profile value\"",
                  "environmentVariables": {
                    "DOTNET_ENVIRONMENT": "Development",
                    "CUSTOM_ENV": "custom-value"
                  }
                },
                "https": {
                  "commandName": "Project",
                  "applicationUrl": "https://should-not-win"
                }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                return (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true",
                        "AspireHostingSDKVersion": "{{VersionHelper.GetDefaultTemplateVersion()}}",
                        "RunCommand": {{appHostCommandJson}},
                        "TargetPath": {{targetPathJson}},
                        "RunWorkingDirectory": {{runWorkingDirectoryJson}},
                        "RunArguments": "--from-msbuild \"two words\"",
                        "TargetFramework": "net10.0"
                      },
                      "Items": {}
                    }
                    """));
            },
            RunAsyncCallback = (_, _, _, _, _, _, _, _, _) => throw new InvalidOperationException("dotnet run should not be used when the built target is known.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAppHostCommandAsyncCallback = (projectFile, command, workingDirectory, args, env, _, options, _) =>
        {
            Assert.Equal(appHostFile.FullName, projectFile.FullName);
            Assert.Equal(appHostCommand.FullName, command);
            Assert.Equal(runWorkingDirectory.FullName, workingDirectory.FullName);
            Assert.False(options.NoLaunchProfile);
            Assert.Equal(
                ["--from-msbuild", "two words", "--explicit", "1"],
                args);
            // The direct command line carries no "--" separator, so the boundary between the
            // MSBuild-supplied arguments and the user's AppHost arguments travels in the options
            // and is what keeps "--explicit 1" out of logs and traces.
            Assert.Equal(2, options.AppHostArgumentStartIndex);
            Assert.Equal(
                "--from-msbuild two words <redacted> <redacted>",
                AppHostArgumentRedactor.RedactFromToString(args, options.AppHostArgumentStartIndex!.Value));
            Assert.NotNull(env);
            Assert.Equal("http", env["DOTNET_LAUNCH_PROFILE"]);
            Assert.Equal("http://localhost:15000", env[KnownAspNetCoreConfigNames.Urls]);
            Assert.Equal("Development", env[KnownAspNetCoreConfigNames.DotNetEnvironment]);
            Assert.Equal("context-value", env["CUSTOM_ENV"]);
            return Task.FromResult(123);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            UnmatchedTokens = ["--explicit", "1"],
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>
            {
                ["CUSTOM_ENV"] = "context-value"
            }
        }, CancellationToken.None);
 
        Assert.Equal(123, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchAppliesSelectedLaunchProfile()
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "Properties", "launchSettings.json"), """
            {
              "profiles": {
                "playwright": {
                  "commandName": "Project",
                  "applicationUrl": "http://localhost:15000",
                  "commandLineArgs": "--from-profile playwright",
                  "environmentVariables": {
                    "SELECTED_PROFILE": "playwright"
                  }
                },
                "E2E": {
                  "commandName": "Project",
                  "applicationUrl": "http://localhost:16000",
                  "commandLineArgs": "--from-profile E2E",
                  "executablePath": false,
                  "workingDirectory": true,
                  "environmentVariables": {
                    "SELECTED_PROFILE": "E2E",
                    "PROFILE_ONLY": "E2E"
                  }
                }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => (0, CreateAppHostInfoJson(runCommand: appHostCommand.FullName)),
            RunAsyncCallback = (_, _, _, _, _, _, _, _, _) => throw new InvalidOperationException("dotnet run should not be used for a selected Project launch profile.")
        };
        var project = CreateDotNetAppHostProject(runner);
        var services = CliTestHelper.CreateServiceCollection(_workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
        var rootCommand = provider.GetRequiredService<RootCommand>();
        var parseResult = rootCommand.Parse(
        [
            "run",
            "--non-interactive",
            "--apphost", appHostFile.FullName,
            "--log-file", Path.Combine(_workspace.WorkspaceRoot.FullName, "child.log"),
            "--launch-profile=e2e"
        ]);
 
        Assert.Empty(parseResult.Errors);
        Assert.Empty(parseResult.UnmatchedTokens);
 
        runner.RunAppHostCommandAsyncCallback = (_, command, _, args, env, _, options, _) =>
        {
            Assert.Equal(appHostCommand.FullName, command);
            Assert.Equal("e2e", options.LaunchProfile);
            Assert.Equal(["--from-profile", "E2E"], args);
            Assert.NotNull(env);
            Assert.Equal("e2e", env["DOTNET_LAUNCH_PROFILE"]);
            Assert.Equal("http://localhost:16000", env[KnownAspNetCoreConfigNames.Urls]);
            Assert.Equal("from-context", env["SELECTED_PROFILE"]);
            Assert.Equal("E2E", env["PROFILE_ONLY"]);
            return Task.FromResult(125);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            LaunchProfile = parseResult.GetValue(AppHostLauncher.s_launchProfileOption),
            NoBuild = false,
            NoRestore = false,
            UnmatchedTokens = parseResult.UnmatchedTokens.ToArray(),
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>
            {
                ["SELECTED_PROFILE"] = "from-context"
            }
        }, CancellationToken.None);
 
        Assert.Equal(125, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostMissingSelectedLaunchProfileFallsBackWithoutSelectingDefault()
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "Properties", "launchSettings.json"), """
            {
              "profiles": {
                "playwright": {
                  "commandName": "Project",
                  "environmentVariables": {
                    "SELECTED_PROFILE": "playwright"
                  }
                }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => (0, CreateAppHostInfoJson(runCommand: appHostCommand.FullName)),
            RunAppHostCommandAsyncCallback = (_, _, _, _, _, _, _, _) => throw new InvalidOperationException("direct launch should not substitute the default profile.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAsyncCallback = (_, watch, noBuild, noRestore, args, env, _, options, _) =>
        {
            Assert.False(watch);
            Assert.True(noBuild);
            Assert.False(noRestore);
            Assert.Empty(args);
            Assert.Equal("missing", options.LaunchProfile);
            Assert.NotNull(env);
            Assert.False(env.ContainsKey("SELECTED_PROFILE"));
            return Task.FromResult(126);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            LaunchProfile = "missing",
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(126, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostSelectedLaunchProfileWithoutLaunchSettingsFallsBack()
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => (0, CreateAppHostInfoJson(runCommand: appHostCommand.FullName)),
            RunAppHostCommandAsyncCallback = (_, _, _, _, _, _, _, _) => throw new InvalidOperationException("direct launch should not ignore an explicit launch profile.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAsyncCallback = (_, _, noBuild, _, _, _, _, options, _) =>
        {
            Assert.True(noBuild);
            Assert.Equal("E2E", options.LaunchProfile);
            return Task.FromResult(127);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            LaunchProfile = "E2E",
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(127, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostAmbiguousSelectedLaunchProfileFallsBack()
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "Properties", "launchSettings.json"), """
            {
              "profiles": {
                "E2E": { "commandName": "Project" },
                "e2e": { "commandName": "Project" }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => (0, CreateAppHostInfoJson(runCommand: appHostCommand.FullName)),
            RunAppHostCommandAsyncCallback = (_, _, _, _, _, _, _, _) => throw new InvalidOperationException("direct launch should not choose between ambiguous profile names.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAsyncCallback = (_, _, _, _, _, _, _, options, _) =>
        {
            Assert.Equal("e2e", options.LaunchProfile);
            return Task.FromResult(128);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            LaunchProfile = "e2e",
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(128, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostSelectedLaunchProfileWithInvalidCommandNameCasingFallsBack()
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "Properties", "launchSettings.json"), """
            {
              "profiles": {
                "E2E": { "commandName": "project" }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => (0, CreateAppHostInfoJson(runCommand: appHostCommand.FullName)),
            RunAppHostCommandAsyncCallback = (_, _, _, _, _, _, _, _) => throw new InvalidOperationException("direct launch should not accept an SDK-invalid command name.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAsyncCallback = (_, _, _, _, _, _, _, options, _) =>
        {
            Assert.Equal("E2E", options.LaunchProfile);
            return Task.FromResult(129);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            LaunchProfile = "E2E",
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(129, exitCode);
    }
 
    [Theory]
    [InlineData("\"launchBrowser\": \"yes\"")]
    [InlineData("\"launchUrl\": true")]
    [InlineData("\"dotnetRunMessages\": \"yes\"")]
    public async Task RunAsync_ProjectAppHostSelectedLaunchProfileWithInvalidSdkPropertyFallsBack(string invalidProperty)
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "Properties", "launchSettings.json"), $$"""
            {
              "profiles": {
                "E2E": {
                  "commandName": "Project",
                  {{invalidProperty}},
                  "environmentVariables": { "PROFILE": "direct-launch" }
                }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => (0, CreateAppHostInfoJson(runCommand: appHostCommand.FullName)),
            RunAppHostCommandAsyncCallback = (_, _, _, _, _, _, _, _) => throw new InvalidOperationException("direct launch should not accept a profile that the SDK cannot deserialize.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAsyncCallback = (_, _, _, _, _, env, _, options, _) =>
        {
            Assert.Equal("E2E", options.LaunchProfile);
            Assert.NotNull(env);
            Assert.False(env.ContainsKey("PROFILE"));
            return Task.FromResult(131);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            LaunchProfile = "E2E",
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(131, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDuplicateSelectedLaunchProfileFallsBack()
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "Properties", "launchSettings.json"), """
            {
              "profiles": {
                "E2E": { "commandName": "Project", "environmentVariables": { "PROFILE": "first" } },
                "E2E": { "commandName": "Project", "environmentVariables": { "PROFILE": "second" } }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => (0, CreateAppHostInfoJson(runCommand: appHostCommand.FullName)),
            RunAppHostCommandAsyncCallback = (_, _, _, _, _, _, _, _) => throw new InvalidOperationException("direct launch should not collapse duplicate profile properties.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAsyncCallback = (_, _, _, _, _, env, _, options, _) =>
        {
            Assert.Equal("e2e", options.LaunchProfile);
            Assert.NotNull(env);
            Assert.False(env.ContainsKey("PROFILE"));
            return Task.FromResult(130);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            LaunchProfile = "e2e",
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(130, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchSetsWorkloadIdAndPreservesLaunchProfileInputs()
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
        Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "Properties", "launchSettings.json"), """
            {
              "profiles": {
                "http": {
                  "commandName": "Project",
                  "commandLineArgs": "--from-profile profile-value",
                  "environmentVariables": {
                    "CUSTOM_ENV": "from-profile",
                    "DCP_WORKLOAD_ID": "from-profile"
                  }
                }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => (0, CreateAppHostInfoJson(runCommand: appHostCommand.FullName)),
            RunAsyncCallback = (_, _, _, _, _, _, _, _, _) => throw new InvalidOperationException("dotnet run should not be used when the built target is known.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAppHostCommandAsyncCallback = (_, command, _, args, env, _, options, _) =>
        {
            Assert.Equal(appHostCommand.FullName, command);
            Assert.False(options.NoLaunchProfile);
            Assert.Equal(["--from-profile", "profile-value"], args);
            Assert.NotNull(env);
            Assert.Equal("http", env["DOTNET_LAUNCH_PROFILE"]);
            Assert.Equal("from-profile", env["CUSTOM_ENV"]);
            Assert.Equal(expectedWorkloadId, env[KnownConfigNames.DcpWorkloadId]);
            return Task.FromResult(124);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(124, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchCanBeDisabledByConfig()
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        var appHostCommandJson = JsonSerializer.Serialize(appHostCommand.FullName);
        WriteAspireConfigJson(appHostFile.DirectoryName!, """
            {
              "dotnetAppHostDirectLaunchDisabled": "true"
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                return (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true",
                        "AspireHostingSDKVersion": "{{VersionHelper.GetDefaultTemplateVersion()}}",
                        "RunCommand": {{appHostCommandJson}},
                        "TargetFramework": "net10.0"
                      },
                      "Items": {}
                    }
                    """));
            },
            RunAppHostCommandAsyncCallback = (_, _, _, _, _, _, _, _) => throw new InvalidOperationException("direct AppHost launch should not be used when disabled.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, _, _, _, _) =>
        {
            Assert.Equal(appHostFile.FullName, projectFile.FullName);
            Assert.False(watch);
            Assert.True(noBuild);
            Assert.False(noRestore);
            Assert.Equal(["--explicit", "1"], args);
            return Task.FromResult(77);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            UnmatchedTokens = ["--explicit", "1"],
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(77, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchFallsBackWhenDotnetExecTargetIsMissing()
    {
        var missingTargetPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "bin", "missing", "AppHost.dll");
 
        var exitCode = await AssertProjectAppHostFallsBackToDotNetRunAsync(
            () => CreateAppHostInfoJson(runCommand: "dotnet", runArguments: $"exec \"{missingTargetPath}\""));
 
        Assert.Equal(77, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchFallsBackWhenRuntimeConfigIsMissing()
    {
        var outputDirectory = Directory.CreateDirectory(Path.Combine(_workspace.WorkspaceRoot.FullName, "bin", Guid.NewGuid().ToString("N")));
        var targetPath = Path.Combine(outputDirectory.FullName, "AppHost.dll");
        File.WriteAllText(targetPath, string.Empty);
 
        var exitCode = await AssertProjectAppHostFallsBackToDotNetRunAsync(
            () => CreateAppHostInfoJson(runCommand: "dotnet", runArguments: $"exec \"{targetPath}\""));
 
        Assert.Equal(77, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchFallsBackWhenNativeRunCommandIsMissing()
    {
        var missingRunCommand = Path.Combine(_workspace.WorkspaceRoot.FullName, "bin", "missing", "AppHost");
 
        var exitCode = await AssertProjectAppHostFallsBackToDotNetRunAsync(
            () => CreateAppHostInfoJson(runCommand: missingRunCommand));
 
        Assert.Equal(77, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchFallsBackForMultiTargetedAppHost()
    {
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
 
        var exitCode = await AssertProjectAppHostFallsBackToDotNetRunAsync(
            () => CreateAppHostInfoJson(runCommand: appHostCommand.FullName, targetFrameworks: "net10.0;net9.0"));
 
        Assert.Equal(77, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchFallsBackWhenRunCommandIsMissing()
    {
        var exitCode = await AssertProjectAppHostFallsBackToDotNetRunAsync(
            () => CreateAppHostInfoJson(runCommand: "   "));
 
        Assert.Equal(77, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchFallsBackWhenDotnetRunArgumentsDoNotUseExec()
    {
        var exitCode = await AssertProjectAppHostFallsBackToDotNetRunAsync(
            () => CreateAppHostInfoJson(runCommand: "dotnet", runArguments: "run --project AppHost.csproj"));
 
        Assert.Equal(77, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostUsesSdkPathWhenWatchIsEnabled()
    {
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
 
        var exitCode = await AssertProjectAppHostFallsBackToDotNetRunAsync(
            () => CreateAppHostInfoJson(runCommand: appHostCommand.FullName),
            expectedWatch: true,
            configureServices: options => options.EnabledFeatures = [KnownFeatures.DefaultWatchEnabled]);
 
        Assert.Equal(77, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostUsesSdkPathForExtensionHost()
    {
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
 
        var exitCode = await AssertProjectAppHostFallsBackToDotNetRunAsync(
            () => CreateAppHostInfoJson(runCommand: appHostCommand.FullName),
            configureServices: options =>
            {
                options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel();
                options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
            });
 
        Assert.Equal(77, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchUsesProfileArgsWhenRunCommandHasNoArgs()
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        var appHostCommandJson = JsonSerializer.Serialize(appHostCommand.FullName);
        Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "Properties", "launchSettings.json"), """
            {
              "profiles": {
                "http": {
                  "commandName": "Project",
                  "commandLineArgs": "--from-profile \"profile value\""
                }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                return (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true",
                        "AspireHostingSDKVersion": "{{VersionHelper.GetDefaultTemplateVersion()}}",
                        "RunCommand": {{appHostCommandJson}},
                        "TargetFramework": "net10.0"
                      },
                      "Items": {}
                    }
                    """));
            },
            RunAsyncCallback = (_, _, _, _, _, _, _, _, _) => throw new InvalidOperationException("dotnet run should not be used when the built target is known.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAppHostCommandAsyncCallback = (_, command, _, args, env, _, _, _) =>
        {
            Assert.Equal(appHostCommand.FullName, command);
            Assert.Equal(["--from-profile", "profile value"], args);
            Assert.NotNull(env);
            Assert.Equal("http", env["DOTNET_LAUNCH_PROFILE"]);
            return Task.FromResult(88);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(88, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchExpandsLaunchSettingsEnvironmentVariablesAndArgs()
    {
        var variableName = $"ASPIRE_TEST_EXPAND_{Guid.NewGuid():N}";
        var variableReference = $"%{variableName}%";
        var previousValue = Environment.GetEnvironmentVariable(variableName);
        Environment.SetEnvironmentVariable(variableName, "expanded-value");
 
        try
        {
            var appHostFile = CreateProjectAppHost();
            var appHostCommand = CreateBuiltAppHostCommand("AppHost");
            Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
            File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "Properties", "launchSettings.json"), $$"""
                {
                  "profiles": {
                    "http": {
                      "commandName": "Project",
                      "commandLineArgs": "--expanded {{variableReference}}",
                      "environmentVariables": {
                        "CUSTOM_ENV": "{{variableReference}}/child"
                      }
                    }
                  }
                }
                """);
 
            var runner = new TestDotNetCliRunner
            {
                BuildAsyncCallback = (_, _, _, _) => 0,
                GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => (0, CreateAppHostInfoJson(runCommand: appHostCommand.FullName)),
                RunAsyncCallback = (_, _, _, _, _, _, _, _, _) => throw new InvalidOperationException("dotnet run should not be used when the built target is known.")
            };
            var project = CreateDotNetAppHostProject(runner);
 
            runner.RunAppHostCommandAsyncCallback = (_, _, _, args, env, _, _, _) =>
            {
                Assert.Equal(["--expanded", "expanded-value"], args);
                Assert.NotNull(env);
                Assert.Equal("expanded-value/child", env["CUSTOM_ENV"]);
                return Task.FromResult(88);
            };
 
            var exitCode = await project.RunAsync(new AppHostProjectContext
            {
                AppHostFile = appHostFile,
                NoBuild = false,
                NoRestore = false,
                WorkingDirectory = _workspace.WorkspaceRoot,
                EnvironmentVariables = new Dictionary<string, string>()
            }, CancellationToken.None);
 
            Assert.Equal(88, exitCode);
        }
        finally
        {
            Environment.SetEnvironmentVariable(variableName, previousValue);
        }
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchPreservesDotnetExecRunArguments()
    {
        var appHostFile = CreateProjectAppHost();
        var targetPath = CreateBuiltAppHostAssembly("App Host.dll");
        var escapedTargetPath = targetPath.FullName.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                return (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true",
                        "AspireHostingSDKVersion": "{{VersionHelper.GetDefaultTemplateVersion()}}",
                        "RunCommand": "dotnet.exe",
                        "RunArguments": "exec \"{{escapedTargetPath}}\" --from-msbuild",
                        "TargetFramework": "net10.0"
                      },
                      "Items": {}
                    }
                    """));
            },
            RunAsyncCallback = (_, _, _, _, _, _, _, _, _) => throw new InvalidOperationException("dotnet run should not be used when the built target is known.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAppHostCommandAsyncCallback = (_, command, _, args, _, _, _, _) =>
        {
            Assert.Equal("dotnet.exe", command);
            Assert.Equal(["exec", targetPath.FullName, "--from-msbuild", "--explicit", "1"], args);
            return Task.FromResult(99);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            UnmatchedTokens = ["--explicit", "1"],
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(99, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchReadsFlatRunJson()
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        var appHostCommandJson = JsonSerializer.Serialize(appHostCommand.FullName);
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "AppHost.run.json"), """
            {
              "profiles": {
                "flat": {
                  "commandName": "Project",
                  "applicationUrl": "http://localhost:16000",
                  "environmentVariables": {
                    "CUSTOM_ENV": "from-run-json"
                  }
                }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                return (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true",
                        "AspireHostingSDKVersion": "{{VersionHelper.GetDefaultTemplateVersion()}}",
                        "RunCommand": {{appHostCommandJson}},
                        "TargetFramework": "net10.0"
                      },
                      "Items": {}
                    }
                    """));
            },
            RunAsyncCallback = (_, _, _, _, _, _, _, _, _) => throw new InvalidOperationException("dotnet run should not be used when the built target is known.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAppHostCommandAsyncCallback = (_, command, _, args, env, _, _, _) =>
        {
            Assert.Equal(appHostCommand.FullName, command);
            Assert.Empty(args);
            Assert.NotNull(env);
            Assert.Equal("flat", env["DOTNET_LAUNCH_PROFILE"]);
            Assert.Equal("http://localhost:16000", env[KnownAspNetCoreConfigNames.Urls]);
            Assert.Equal("from-run-json", env["CUSTOM_ENV"]);
            return Task.FromResult(101);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(101, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_VbProjectAppHostDirectLaunchReadsMyProjectLaunchSettings()
    {
        var appHostFile = CreateProjectAppHost("AppHost.vbproj");
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        var appHostCommandJson = JsonSerializer.Serialize(appHostCommand.FullName);
        Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "My Project"));
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "My Project", "launchSettings.json"), """
            {
              "profiles": {
                "vb": {
                  "commandName": "Project",
                  "applicationUrl": "http://localhost:17000"
                }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                return (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true",
                        "AspireHostingSDKVersion": "{{VersionHelper.GetDefaultTemplateVersion()}}",
                        "RunCommand": {{appHostCommandJson}},
                        "TargetFramework": "net10.0"
                      },
                      "Items": {}
                    }
                    """));
            },
            RunAsyncCallback = (_, _, _, _, _, _, _, _, _) => throw new InvalidOperationException("dotnet run should not be used when the built target is known.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAppHostCommandAsyncCallback = (_, command, _, _, env, _, _, _) =>
        {
            Assert.Equal(appHostCommand.FullName, command);
            Assert.NotNull(env);
            Assert.Equal("vb", env["DOTNET_LAUNCH_PROFILE"]);
            Assert.Equal("http://localhost:17000", env[KnownAspNetCoreConfigNames.Urls]);
            return Task.FromResult(102);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(102, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostFallsBackToDotnetRunForExecutableLaunchProfile()
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        var appHostCommandJson = JsonSerializer.Serialize(appHostCommand.FullName);
        Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "Properties", "launchSettings.json"), """
            {
              "profiles": {
                "tool": {
                  "commandName": "Executable",
                  "executablePath": "custom-tool"
                }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                return (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true",
                        "AspireHostingSDKVersion": "{{VersionHelper.GetDefaultTemplateVersion()}}",
                        "RunCommand": {{appHostCommandJson}},
                        "TargetFramework": "net10.0"
                      },
                      "Items": {}
                    }
                    """));
            },
            RunAppHostCommandAsyncCallback = (_, _, _, _, _, _, _, _) => throw new InvalidOperationException("direct AppHost launch should not be used for executable launch profiles.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, _, _, options, _) =>
        {
            Assert.Equal(appHostFile.FullName, projectFile.FullName);
            Assert.False(watch);
            Assert.True(noBuild);
            Assert.False(noRestore);
            Assert.Equal(["--explicit", "1"], args);
            Assert.Equal("TOOL", options.LaunchProfile);
            return Task.FromResult(103);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            LaunchProfile = "TOOL",
            NoBuild = false,
            NoRestore = false,
            UnmatchedTokens = ["--explicit", "1"],
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(103, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostFallsBackToDotnetRunWhenLaunchSettingsProfilesIsNull()
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        var appHostCommandJson = JsonSerializer.Serialize(appHostCommand.FullName);
        Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "Properties", "launchSettings.json"), """
            {
              "profiles": null
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                return (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true",
                        "AspireHostingSDKVersion": "{{VersionHelper.GetDefaultTemplateVersion()}}",
                        "RunCommand": {{appHostCommandJson}},
                        "TargetFramework": "net10.0"
                      },
                      "Items": {}
                    }
                    """));
            },
            RunAppHostCommandAsyncCallback = (_, _, _, _, _, _, _, _) => throw new InvalidOperationException("direct AppHost launch should not be used when launch settings do not contain a usable profile.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, _, _, _, _) =>
        {
            Assert.Equal(appHostFile.FullName, projectFile.FullName);
            Assert.False(watch);
            Assert.True(noBuild);
            Assert.False(noRestore);
            Assert.Empty(args);
            return Task.FromResult(104);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(104, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_ProjectAppHostDirectLaunchSkipsNullLaunchSettingsProfileAndEnvironmentValues()
    {
        var appHostFile = CreateProjectAppHost();
        var appHostCommand = CreateBuiltAppHostCommand("AppHost");
        var appHostCommandJson = JsonSerializer.Serialize(appHostCommand.FullName);
        Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "Properties", "launchSettings.json"), """
            {
              "profiles": {
                "null-profile": null,
                "http": {
                  "commandName": "Project",
                  "environmentVariables": {
                    "NULL_ENV": null,
                    "CUSTOM_ENV": "custom-value"
                  }
                }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                return (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true",
                        "AspireHostingSDKVersion": "{{VersionHelper.GetDefaultTemplateVersion()}}",
                        "RunCommand": {{appHostCommandJson}},
                        "TargetFramework": "net10.0"
                      },
                      "Items": {}
                    }
                    """));
            },
            RunAsyncCallback = (_, _, _, _, _, _, _, _, _) => throw new InvalidOperationException("dotnet run should not be used when a later launch profile is usable.")
        };
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAppHostCommandAsyncCallback = (_, command, _, _, env, _, _, _) =>
        {
            Assert.Equal(appHostCommand.FullName, command);
            Assert.NotNull(env);
            Assert.Equal("http", env["DOTNET_LAUNCH_PROFILE"]);
            Assert.Equal("custom-value", env["CUSTOM_ENV"]);
            Assert.False(env.ContainsKey("NULL_ENV"));
            return Task.FromResult(105);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(105, exitCode);
    }
 
    [Fact]
    public async Task RunAsync_SingleFileAppHostUsingCliBundlePassesBundleEnvironmentToRunner()
    {
        UseFakeRepoRoot();
        var appHostFile = CreateSingleFileAppHost(useCliBundle: true);
        var bundleRoot = CreateCliBundle(out var layout);
 
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (projectFile, _, _, _) =>
            {
                Assert.Equal(appHostFile.FullName, projectFile.FullName);
                return 0;
            },
            GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, properties, _, _) =>
            {
                Assert.Equal(appHostFile.FullName, projectFile.FullName);
                Assert.Contains("AspireUseCliBundle", properties);
                return (0, JsonDocument.Parse("""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "AspireUseCliBundle": "true"
                      },
                      "Items": {}
                    }
                    """));
            }
        };
        var project = CreateDotNetAppHostProject(runner, layout);
 
        runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, _, options, _) =>
        {
            Assert.Equal(appHostFile.FullName, projectFile.FullName);
            Assert.False(watch);
            Assert.True(noBuild);
            Assert.False(noRestore);
            Assert.False(options.NoLaunchProfile);
            Assert.Equal(Path.Combine(bundleRoot.FullName, BundleDiscovery.DcpDirectoryName), env![BundleDiscovery.DcpPathEnvVar]);
            Assert.Equal(
                Path.Combine(bundleRoot.FullName, BundleDiscovery.ManagedDirectoryName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)),
                env[BundleDiscovery.DashboardPathEnvVar]);
            Assert.Equal(
                Path.Combine(bundleRoot.FullName, BundleDiscovery.ManagedDirectoryName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)),
                env[BundleDiscovery.TerminalHostPathEnvVar]);
            Assert.Equal("terminalhost", env[BundleDiscovery.TerminalHostInvocationArgsEnvVar]);
            return Task.FromResult(0);
        };
 
        var exitCode = await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task PublishAsync_SingleFileAppHostStripsRunProfileEnvironmentBeforeInvokingRunner()
    {
        var appHostFile = CreateSingleFileAppHost();
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "apphost.run.json"), """
            {
              "profiles": {
                "https": {
                  "applicationUrl": "https://localhost:19000;http://localhost:15000",
                  "environmentVariables": {
                    "ASPIRE_ENVIRONMENT": "Development",
                    "ASPNETCORE_ENVIRONMENT": "Development",
                    "DOTNET_ENVIRONMENT": "Development",
                    "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000",
                    "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000"
                  }
                }
              }
            }
            """);
 
        var runner = new TestDotNetCliRunner();
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, _, options, _) =>
        {
            Assert.Equal(appHostFile.FullName, projectFile.FullName);
            Assert.False(watch);
            Assert.True(noBuild);
            Assert.False(noRestore);
            Assert.True(options.NoLaunchProfile);
            Assert.Equal(["--operation", "publish"], args);
            Assert.Equal("Production", env![KnownAspNetCoreConfigNames.DotNetEnvironment]);
            Assert.False(env.ContainsKey(KnownAspNetCoreConfigNames.Environment));
            Assert.Equal("https://localhost:19000;http://localhost:15000", env[KnownAspNetCoreConfigNames.Urls]);
            Assert.Equal("https://localhost:21000", env["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]);
            Assert.Equal("https://localhost:22000", env["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]);
            Assert.False(env.ContainsKey("ASPIRE_ENVIRONMENT"));
            return Task.FromResult(0);
        };
 
        var exitCode = await project.PublishAsync(new PublishContext
        {
            AppHostFile = appHostFile,
            WorkingDirectory = _workspace.WorkspaceRoot,
            Arguments = ["--operation", "publish"],
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task PublishAsync_SingleFileAppHostUsesEnvironmentArgumentWhenProvided()
    {
        var appHostFile = CreateSingleFileAppHost();
        var runner = new TestDotNetCliRunner();
        var project = CreateDotNetAppHostProject(runner);
 
        runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, _, options, _) =>
        {
            Assert.Equal(appHostFile.FullName, projectFile.FullName);
            Assert.False(watch);
            Assert.True(noBuild);
            Assert.False(noRestore);
            Assert.True(options.NoLaunchProfile);
            Assert.Equal(["--operation", "publish", "--environment", "Staging"], args);
            Assert.Equal("Staging", env![KnownAspNetCoreConfigNames.DotNetEnvironment]);
            Assert.False(env.ContainsKey(KnownAspNetCoreConfigNames.Environment));
            return Task.FromResult(0);
        };
 
        var exitCode = await project.PublishAsync(new PublishContext
        {
            AppHostFile = appHostFile,
            WorkingDirectory = _workspace.WorkspaceRoot,
            Arguments = ["--operation", "publish", "--environment", "Staging"],
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task PublishAsync_SingleFileAppHostUsingCliBundleDoesNotAcquireOrInjectBundle()
    {
        var appHostFile = CreateSingleFileAppHost(useCliBundle: true);
        _ = CreateCliBundle(out var layout);
        var bundleAcquisitionRequested = false;
        var bundleService = new TestBundleService(isBundle: true)
        {
            Layout = layout,
            EnsureExtractedAndAcquireLayoutAsyncCallback = _ =>
            {
                bundleAcquisitionRequested = true;
                return Task.CompletedTask;
            }
        };
        var runner = new TestDotNetCliRunner();
        var project = CreateDotNetAppHostProject(
            runner,
            layout,
            configureServices: options => options.BundleServiceFactory = _ => bundleService);
 
        runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, _, options, _) =>
        {
            Assert.Equal(appHostFile.FullName, projectFile.FullName);
            Assert.False(watch);
            Assert.True(noBuild);
            Assert.False(noRestore);
            Assert.True(options.NoLaunchProfile);
            Assert.Equal(["--operation", "publish"], args);
            Assert.Equal("Production", env![KnownAspNetCoreConfigNames.DotNetEnvironment]);
            Assert.False(env.ContainsKey("AspireCliBundlePath"));
            Assert.False(env.ContainsKey(BundleDiscovery.DcpPathEnvVar));
            Assert.False(env.ContainsKey(BundleDiscovery.DashboardPathEnvVar));
            Assert.False(env.ContainsKey(BundleDiscovery.TerminalHostPathEnvVar));
            return Task.FromResult(0);
        };
 
        var exitCode = await project.PublishAsync(new PublishContext
        {
            AppHostFile = appHostFile,
            WorkingDirectory = _workspace.WorkspaceRoot,
            Arguments = ["--operation", "publish"],
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
        Assert.False(bundleAcquisitionRequested);
    }
 
    [Fact]
    public void ConfigureSingleFileRunEnvironment_AppliesProfileFromAspireConfigJson()
    {
        var appHostFile = CreateSingleFileAppHost();
        WriteAspireConfigJson(appHostFile.DirectoryName!, """
            {
              "appHost": { "path": "apphost.cs" },
              "profiles": {
                "https": {
                  "applicationUrl": "https://myapp.dev.localhost:17050;http://myapp.dev.localhost:15050",
                  "environmentVariables": {
                    "ASPNETCORE_ENVIRONMENT": "Development",
                    "DOTNET_ENVIRONMENT": "Development",
                    "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://myapp.dev.localhost:21050",
                    "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://myapp.dev.localhost:22050"
                  }
                }
              }
            }
            """);
        var env = new Dictionary<string, string>();
 
        DotNetAppHostProject.ConfigureSingleFileRunEnvironment(
            appHostFile,
            env,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("https://myapp.dev.localhost:17050;http://myapp.dev.localhost:15050", env[KnownAspNetCoreConfigNames.Urls]);
        Assert.Equal("https://myapp.dev.localhost:21050", env["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]);
        Assert.Equal("https://myapp.dev.localhost:22050", env["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]);
        // Run path copies profile env vars verbatim (matches what dotnet does when reading apphost.run.json natively).
        Assert.Equal("Development", env[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.Equal("Development", env[KnownAspNetCoreConfigNames.Environment]);
    }
 
    [Fact]
    public void ConfigureSingleFilePublishEnvironment_AppliesProfileFromAspireConfigJson()
    {
        var appHostFile = CreateSingleFileAppHost();
        WriteAspireConfigJson(appHostFile.DirectoryName!, """
            {
              "appHost": { "path": "apphost.cs" },
              "profiles": {
                "https": {
                  "applicationUrl": "https://myapp.dev.localhost:17050;http://myapp.dev.localhost:15050",
                  "environmentVariables": {
                    "ASPNETCORE_ENVIRONMENT": "Development",
                    "DOTNET_ENVIRONMENT": "Development",
                    "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://myapp.dev.localhost:21050",
                    "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://myapp.dev.localhost:22050"
                  }
                }
              }
            }
            """);
        var env = new Dictionary<string, string>();
 
        DotNetAppHostProject.ConfigureSingleFilePublishEnvironment(
            appHostFile,
            env,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("https://myapp.dev.localhost:17050;http://myapp.dev.localhost:15050", env[KnownAspNetCoreConfigNames.Urls]);
        Assert.Equal("https://myapp.dev.localhost:21050", env["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]);
        Assert.Equal("https://myapp.dev.localhost:22050", env["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]);
        // Publish path filters env-name vars from profile, then ApplyEffectiveEnvironment sets DOTNET_ENVIRONMENT=Production.
        Assert.Equal("Production", env[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.False(env.ContainsKey(KnownAspNetCoreConfigNames.Environment));
    }
 
    [Fact]
    public void ConfigureSingleFilePublishEnvironment_AppHostRunJsonWinsOverAspireConfigJson()
    {
        var appHostFile = CreateSingleFileAppHost();
        File.WriteAllText(Path.Combine(appHostFile.DirectoryName!, "apphost.run.json"), """
            {
              "profiles": {
                "https": {
                  "applicationUrl": "https://from-run-json:19000",
                  "environmentVariables": {
                    "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://from-run-json:21000"
                  }
                }
              }
            }
            """);
        WriteAspireConfigJson(appHostFile.DirectoryName!, """
            {
              "appHost": { "path": "apphost.cs" },
              "profiles": {
                "https": {
                  "applicationUrl": "https://from-config-json:17050",
                  "environmentVariables": {
                    "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://from-config-json:21050"
                  }
                }
              }
            }
            """);
        var env = new Dictionary<string, string>();
 
        DotNetAppHostProject.ConfigureSingleFilePublishEnvironment(
            appHostFile,
            env,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("https://from-run-json:19000", env[KnownAspNetCoreConfigNames.Urls]);
        Assert.Equal("https://from-run-json:21000", env["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]);
    }
 
    [Fact]
    public void ConfigureSingleFileRunEnvironment_FallsBackToDefaultsWhenAspireConfigJsonHasNoProfiles()
    {
        var appHostFile = CreateSingleFileAppHost();
        WriteAspireConfigJson(appHostFile.DirectoryName!, """
            {
              "appHost": { "path": "apphost.cs" }
            }
            """);
        var env = new Dictionary<string, string>();
 
        DotNetAppHostProject.ConfigureSingleFileRunEnvironment(
            appHostFile,
            env,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("https://localhost:17193;http://localhost:15069", env[KnownAspNetCoreConfigNames.Urls]);
        Assert.Equal("Development", env[KnownAspNetCoreConfigNames.DotNetEnvironment]);
    }
 
    [Fact]
    public void ConfigureSingleFileRunEnvironment_FallsBackToDefaultsWhenProfileLacksApplicationUrl()
    {
        var appHostFile = CreateSingleFileAppHost();
        WriteAspireConfigJson(appHostFile.DirectoryName!, """
            {
              "appHost": { "path": "apphost.cs" },
              "profiles": {
                "https": {
                  "environmentVariables": {
                    "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://shouldnotapply:21050"
                  }
                }
              }
            }
            """);
        var env = new Dictionary<string, string>();
 
        DotNetAppHostProject.ConfigureSingleFileRunEnvironment(
            appHostFile,
            env,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("https://localhost:17193;http://localhost:15069", env[KnownAspNetCoreConfigNames.Urls]);
        Assert.Equal("https://localhost:21293", env["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]);
    }
 
    [Fact]
    public void ConfigureSingleFileRunEnvironment_SkipsAspireConfigWhenAppHostPathMismatches()
    {
        var appHostFile = CreateSingleFileAppHost();
        WriteAspireConfigJson(appHostFile.DirectoryName!, """
            {
              "appHost": { "path": "other-apphost.cs" },
              "profiles": {
                "https": {
                  "applicationUrl": "https://shouldnotapply:17050"
                }
              }
            }
            """);
        var env = new Dictionary<string, string>();
 
        DotNetAppHostProject.ConfigureSingleFileRunEnvironment(
            appHostFile,
            env,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("https://localhost:17193;http://localhost:15069", env[KnownAspNetCoreConfigNames.Urls]);
    }
 
    [Fact]
    public void ConfigureSingleFileRunEnvironment_FallsBackToDefaultsWhenAspireConfigJsonIsMalformed()
    {
        var appHostFile = CreateSingleFileAppHost();
        WriteAspireConfigJson(appHostFile.DirectoryName!, "{ this is not valid json");
        var env = new Dictionary<string, string>();
 
        DotNetAppHostProject.ConfigureSingleFileRunEnvironment(
            appHostFile,
            env,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("https://localhost:17193;http://localhost:15069", env[KnownAspNetCoreConfigNames.Urls]);
        Assert.Equal("Development", env[KnownAspNetCoreConfigNames.DotNetEnvironment]);
    }
 
    [Fact]
    public void ConfigureSingleFileRunEnvironment_EnvironmentArgumentOverridesProfileDotNetEnvironment()
    {
        var appHostFile = CreateSingleFileAppHost();
        WriteAspireConfigJson(appHostFile.DirectoryName!, """
            {
              "appHost": { "path": "apphost.cs" },
              "profiles": {
                "https": {
                  "applicationUrl": "https://myapp.dev.localhost:17050",
                  "environmentVariables": {
                    "DOTNET_ENVIRONMENT": "Development"
                  }
                }
              }
            }
            """);
        var env = new Dictionary<string, string>();
 
        DotNetAppHostProject.ConfigureSingleFileRunEnvironment(
            appHostFile,
            env,
            inheritedEnvironmentVariables: new Dictionary<string, string?>(),
            args: ["--environment", "Staging"]);
 
        Assert.Equal("Staging", env[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.Equal("https://myapp.dev.localhost:17050", env[KnownAspNetCoreConfigNames.Urls]);
    }
 
    private FileInfo CreateSingleFileAppHost(bool useCliBundle = false)
    {
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.cs");
        var useCliBundleProperty = useCliBundle ? "#:property AspireUseCliBundle=true" : string.Empty;
        File.WriteAllText(appHostPath, """
            #:sdk Aspire.AppHost.Sdk@13.0.0
            {0}
 
            var builder = DistributedApplication.CreateBuilder(args);
            builder.Build().Run();
            """.Replace("{0}", useCliBundleProperty, StringComparison.Ordinal));
 
        return new FileInfo(appHostPath);
    }
 
    private string CreateMatchingSocketFile(string appHostPath, int pid)
    {
        var backchannelsDir = Path.Combine(_workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch");
        Directory.CreateDirectory(backchannelsDir);
 
        var resolvedAppHostPath = PathNormalizer.ResolveSymlinks(appHostPath);
        var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(resolvedAppHostPath, _workspace.WorkspaceRoot.FullName);
        var appHostId = Path.GetFileName(prefix);
        var socketPath = Path.Combine(
            backchannelsDir,
            $"{appHostId}a1b2C3d4.{pid.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
        File.WriteAllText(socketPath, "");
        return socketPath;
    }
 
    private FileInfo CreateBuiltAppHostAssembly(string fileName)
    {
        var outputDirectory = Directory.CreateDirectory(Path.Combine(_workspace.WorkspaceRoot.FullName, "bin", Guid.NewGuid().ToString("N")));
        var targetPath = Path.Combine(outputDirectory.FullName, fileName);
        File.WriteAllText(targetPath, string.Empty);
        File.WriteAllText(Path.ChangeExtension(targetPath, ".runtimeconfig.json"), "{}");
        return new FileInfo(targetPath);
    }
 
    private FileInfo CreateBuiltAppHostCommand(string fileName)
    {
        var outputDirectory = Directory.CreateDirectory(Path.Combine(_workspace.WorkspaceRoot.FullName, "bin", Guid.NewGuid().ToString("N")));
        var commandPath = Path.Combine(outputDirectory.FullName, fileName);
        File.WriteAllText(commandPath, string.Empty);
        return new FileInfo(commandPath);
    }
 
    private async Task<int> AssertProjectAppHostFallsBackToDotNetRunAsync(
        Func<JsonDocument> createAppHostInfo,
        bool expectedWatch = false,
        Action<CliServiceCollectionTestOptions>? configureServices = null)
    {
        var appHostFile = CreateProjectAppHost();
        var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) => 0,
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => (0, createAppHostInfo()),
            RunAppHostCommandAsyncCallback = (_, _, _, _, _, _, _, _) => throw new InvalidOperationException("direct AppHost launch should not be used when the run metadata is not directly launchable.")
        };
        var project = CreateDotNetAppHostProject(runner, configureServices: configureServices);
 
        runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, _, _, _) =>
        {
            Assert.Equal(appHostFile.FullName, projectFile.FullName);
            Assert.Equal(expectedWatch, watch);
            Assert.Equal(!expectedWatch, noBuild);
            Assert.False(noRestore);
            Assert.Empty(args);
            Assert.NotNull(env);
            Assert.Equal(expectedWorkloadId, env[KnownConfigNames.DcpWorkloadId]);
            return Task.FromResult(77);
        };
 
        return await project.RunAsync(new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            NoBuild = false,
            NoRestore = false,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        }, CancellationToken.None);
    }
 
    [Fact]
    public void IsLikelyAppHost_SdkAttributeWithVersion_ReturnsTrue()
    {
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project Sdk="Aspire.AppHost.Sdk/9.5.0" />
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_SdkAttributeWithoutVersion_ReturnsTrue()
    {
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project Sdk="Aspire.AppHost.Sdk" />
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_SdkAttributeWithMultipleSdks_ReturnsTrue()
    {
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project Sdk="Microsoft.NET.Sdk;Aspire.AppHost.Sdk/9.5.0" />
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NestedSdkElement_ReturnsTrue()
    {
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NestedSdkElementInLegacyNamespace_ReturnsTrue()
    {
        // Legacy MSBuild XML namespace projects should be matched the same as SDK-style ones.
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
              <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_IsAspireHostPropertyTrue_ReturnsTrue()
    {
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_IsAspireHostPropertyFalse_ReturnsFalse()
    {
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <IsAspireHost>false</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AppHostNamedProjectWithoutInlineMarker_ReturnsTrue()
    {
        // No inline Aspire marker, but the file name follows the "*.AppHost.csproj" convention, so it is a
        // candidate worth confirming via MSBuild rather than being dropped.
        var projectFile = WriteIsLikelyAppHostProject("Test.AppHost.csproj", """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_OrdinaryProjectWithoutInlineMarker_ReturnsFalse()
    {
        // Ordinary name, parseable content, and no Aspire signal: not a candidate, so it never reaches MSBuild.
        var projectFile = WriteIsLikelyAppHostProject("Library.csproj", """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_SiblingAppHostSourceFile_ReturnsTrue()
    {
        // The project has no inline marker and an ordinary name, but a sibling AppHost.cs (shipped by
        // project-based AppHosts created with `aspire new`) marks it as a candidate. The file is written with
        // its real PascalCase name so this also guards against a case-sensitive lookup missing it on
        // Linux/macOS.
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("AppHost.cs", "// builder entrypoint");
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_SiblingAppHostSourceFileWithDifferentCasing_ReturnsTrue()
    {
        // The sibling source-file match is case-insensitive (preserving the prior discovery behavior), so a
        // differently-cased apphost.cs next to an ordinary project is still treated as a candidate.
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("apphost.cs", "// builder entrypoint");
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_UnparseableAppHostNamedCsproj_ReturnsTrue()
    {
        // Malformed XML can't be parsed, so the verdict falls back to the name heuristic. The
        // "*.AppHost.csproj" name keeps it a candidate so a broken AppHost can still surface as
        // possibly-unbuildable rather than being silently dropped.
        var projectFile = WriteIsLikelyAppHostProject("Test.AppHost.csproj", "<Project Sdk=\"Microsoft.NET.Sdk\"");
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_UnparseableOrdinaryCsproj_ReturnsFalse()
    {
        // Malformed XML falls back to the name heuristic; an ordinary name with no sibling apphost.cs is not
        // a candidate, so a broken ordinary project is not dragged onto the MSBuild path.
        var projectFile = WriteIsLikelyAppHostProject("Library.csproj", "<Project Sdk=\"Microsoft.NET.Sdk\"");
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_FsprojWithSdkAttribute_ReturnsTrue()
    {
        // Detection is language-agnostic: the same MSBuild XML signals are inspected for .fsproj/.vbproj
        // AppHosts, not just .csproj.
        var projectFile = WriteIsLikelyAppHostProject("MyHost.fsproj", """
            <Project Sdk="Aspire.AppHost.Sdk/9.5.0" />
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_CoLocatedDirectoryBuildPropsSetsIsAspireHost_ReturnsTrue()
    {
        // Real-world regression case: the project file has no inline marker and an ordinary name, but a
        // co-located Directory.Build.props (auto-imported by MSBuild) sets <IsAspireHost>true</IsAspireHost>,
        // which promotes it to an AppHost during evaluation. The repo's own tests/Aspire.Hosting.Tests does
        // exactly this, and it must remain a candidate.
        var projectFile = WriteIsLikelyAppHostProject("Aspire.Hosting.Tests.csproj", """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("Directory.Build.props", """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_CoLocatedDirectoryBuildTargetsImportsAppHostSdk_ReturnsTrue()
    {
        // A co-located Directory.Build.targets that imports the Aspire.AppHost.Sdk also promotes an
        // otherwise ordinary-looking project to an AppHost during evaluation.
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("Directory.Build.targets", """
            <Project>
              <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_CoLocatedDirectoryBuildFileOnlyConsumesIsAspireHost_ReturnsFalse()
    {
        // A co-located build file that merely *reads* IsAspireHost in a condition (never sets it) must not be
        // treated as a marker. The playground's Directory.Build.targets does this with
        // Condition="'$(IsAspireHost)' == 'true'"; a loose substring match would over-promote every sibling
        // project. Matching on the property *element* threads this needle.
        var projectFile = WriteIsLikelyAppHostProject("Library.csproj", """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("Directory.Build.targets", """
            <Project>
              <PropertyGroup Condition="'$(IsAspireHost)' == 'true'">
                <SomeSetting>value</SomeSetting>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildPropsSetsIsAspireHost_ReturnsTrue()
    {
        // Real-world regression scenario: MSBuild walks up the directory tree to import
        // Directory.Build.props from the *nearest* ancestor that has one (not just the project's
        // own directory). A repo can park <IsAspireHost>true</IsAspireHost> in a parent
        // Directory.Build.props and ship multiple AppHost csprojs underneath it. The cheap
        // pre-check must not reject those AppHosts before MSBuild even gets a chance to evaluate
        // them, or `aspire run` against a settings/explicit path will silently fail.
        // See https://learn.microsoft.com/visualstudio/msbuild/customize-by-directory#search-scope
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "MyHost", "MyHost.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("Directory.Build.props", """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildTargetsImportsAppHostSdk_ReturnsTrue()
    {
        // Same MSBuild walk-up applies to Directory.Build.targets: a parent file importing
        // Aspire.AppHost.Sdk must promote the descendant project to an AppHost candidate.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "MyHost", "MyHost.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("Directory.Build.targets", """
            <Project>
              <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ImportElementWithAppHostSdkAttribute_ReturnsTrue()
    {
        // The "Import an SDK into your project" form is another supported way to bring in an
        // MSBuild SDK and is functionally equivalent to <Sdk Name="..."/>. Documented at:
        // https://learn.microsoft.com/visualstudio/msbuild/how-to-use-project-sdk#import-an-sdk-into-your-project
        // A project using this form is a real AppHost and must survive the cheap pre-check.
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="Sdk.props" Sdk="Aspire.AppHost.Sdk" Version="9.5.0" />
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
              <Import Project="Sdk.targets" Sdk="Aspire.AppHost.Sdk" Version="9.5.0" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ImportElementWithUnrelatedSdkAttribute_ReturnsFalse()
    {
        // Imports that pull in unrelated SDKs (e.g. AOT publishing extensions) are not AppHost
        // markers. Match on the SDK identifier rather than the mere presence of an <Import Sdk=...>
        // attribute so non-AppHost projects with custom imports are still cheaply rejected.
        var projectFile = WriteIsLikelyAppHostProject("Library.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="Sdk.props" Sdk="Some.Other.Sdk" Version="1.0.0" />
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ProjectFileContainsDynamicWalkUpImport_ReturnsTrue()
    {
        // Project-file analog of AncestorDirectoryBuildPropsContainsImportButNoMarker: a normal-named
        // .csproj whose only AppHost signal is a dynamic walk-up Import — for example
        //   <Import Project="$([MSBuild]::GetPathOfFileAbove('Aspire.Common.props', ...))" />
        // pointing at a shared file that sets <IsAspireHost>true</IsAspireHost> or imports
        // Aspire.AppHost.Sdk. The cheap pre-check cannot statically resolve where the import lands,
        // so we must treat the project as a candidate and let MSBuild's authoritative evaluation
        // decide rather than silently rejecting it before ValidateAppHostAsync ever consults MSBuild.
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Aspire.Common.props', '$(MSBuildThisFileDirectory)../'))" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ProjectFileConventionalWalkUpImportBypassesNearestBuildFile_ReturnsTrue()
    {
        // A conventional walk-up import is only redundant while following a Directory.Build.* chain.
        // In the project file itself, starting at the parent can bypass a co-located marker-less
        // Directory.Build.props and explicitly import an outer marker-bearing file.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "project", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "project", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ProjectFileContainsLowerCaseDynamicWalkUpImport_ReturnsTrue()
    {
        // MSBuild property function names are case-insensitive: evaluating
        //   $([MSBuild]::getpathoffileabove('Foo.props', '$(MSBuildThisFileDirectory)../'))
        // and
        //   $([MSBuild]::GetPathOfFileAbove('Foo.props', '$(MSBuildThisFileDirectory)../'))
        // produces the same resolved path. A case-sensitive pre-check would silently filter out the
        // lower-case variant before MSBuild evaluation, leaving the same false-negative window the
        // dynamic walk-up fallback is meant to close.
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([MSBuild]::getpathoffileabove('Aspire.Common.props', '$(MSBuildThisFileDirectory)../'))" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildPropsContainsMixedCaseDynamicWalkUpImport_ReturnsTrue()
    {
        // Mixed-case variant on an ancestor Directory.Build.props — same rationale as the
        // project-file lower-case test, but at the ancestor level. Real-world Directory.Build files
        // are author-controlled and a casing tweak in upstream samples must not turn the pre-check
        // into a silent rejection path.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "MyHost", "MyHost.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("Directory.Build.props", """
            <Project>
              <Import Project="$([MSBuild]::getDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)..', 'Shared.props'))/Shared.props" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ProjectFileContainsUnrelatedStaticImports_ReturnsFalse()
    {
        // Symmetric negative for project-file imports: a normal-named .csproj that imports Arcade,
        // a common analyzer polyfill, and a relative Versions.props (all statically-named) has no
        // plausible path to declaring an Aspire marker and must NOT be promoted. Without this guard
        // the project-level dynamic-import fallback would over-promote ordinary library projects in
        // this repo, whose csprojs frequently look exactly like this.
        var projectFile = WriteIsLikelyAppHostProject("Library.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="Sdk.props" Sdk="Microsoft.DotNet.Arcade.Sdk" />
              <Import Project="NullablePolyfill.targets" />
              <Import Project="../Versions.props" />
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildPropsConventionalChainingNoMarker_ReturnsFalse()
    {
        // Real shape lifted from this repo's own src/Directory.Build.props, tests/Directory.Build.props
        // and tests/Directory.Build.targets: conventional Directory.Build.* chaining via
        //   <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props',  ...))" />
        //   <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', ...))" />
        // The ancestor walk already enumerates Directory.Build.props and Directory.Build.targets at
        // every parent level by name, so the chain delivers no content the pre-check cannot already
        // see. Treating this as "uncertain" over-promotes every ordinary project under src/, tests/,
        // and similar repo layouts.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "Library", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("src", "Directory.Build.props"), """
            <Project>
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("src", "Directory.Build.targets"), """
            <Project>
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)../'))" />
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildFileOnlyConsumesIsAspireHost_ReturnsFalse()
    {
        // Mirrors the co-located negative case: an ancestor file that merely reads $(IsAspireHost)
        // in a Condition is not a setter and must not over-promote every descendant project.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "Library", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("Directory.Build.targets", """
            <Project>
              <PropertyGroup Condition="'$(IsAspireHost)' == 'true'">
                <SomeSetting>value</SomeSetting>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ImportProjectPathContainsLiteralWalkUpHelperName_ReturnsFalse()
    {
        // The walk-up fallback must distinguish between an MSBuild *function call* — name followed
        // by `(` — and a static path that merely contains the helper name as text. Authors are free
        // to name files using the helper names; doing so does not turn the import into a dynamic
        // walk-up. Without this distinction the cheap pre-check over-promotes ordinary projects
        // whose csprojs reference files whose names happen to contain "GetPathOfFileAbove" or
        // "GetDirectoryNameOfFileAbove".
        var projectFile = WriteIsLikelyAppHostProject("Library.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="build/GetPathOfFileAbove.props" />
              <Import Project="../getdirectorynameoffileabove.targets" />
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ProjectFileLowerCaseIsAspireHostProperty_ReturnsTrue()
    {
        // MSBuild property names are case-insensitive: <isaspirehost>true</isaspirehost> sets
        // $(IsAspireHost) just as <IsAspireHost>true</IsAspireHost> does. Matching the property
        // element case-sensitively would silently reject a real AppHost that uses lower- or
        // mixed-case marker syntax.
        // Docs: https://learn.microsoft.com/visualstudio/msbuild/msbuild-properties
        var projectFile = WriteIsLikelyAppHostProject("MyHost.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <isaspirehost>true</isaspirehost>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildPropsMixedCaseIsAspireHostProperty_ReturnsTrue()
    {
        // Ancestor analog of the lower-case marker test. Author-controlled build files can use any
        // casing for property elements; MSBuild treats them as the same property at evaluation time,
        // so the pre-check must as well.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "MyHost", "MyHost.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("Directory.Build.props", """
            <Project>
              <PropertyGroup>
                <ISASPIREHOST>true</ISASPIREHOST>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildPropsContainsUnrelatedStaticImports_ReturnsFalse()
    {
        // Real shape lifted from this repo's own root Directory.Build.props/.targets: ordinary static
        // imports that pull in Arcade, common analyzer polyfills, or similar shared infrastructure but
        // never set IsAspireHost or import Aspire.AppHost.Sdk. Without this narrowing, the conservative
        // fallback would treat *every* descendant project as "possibly an AppHost" in a typical .NET
        // repo, which defeats the purpose of the cheap pre-check. Only genuinely unresolved/dynamic
        // imports (e.g. <Import Project="$([MSBuild]::GetPathOfFileAbove(...))" />) should trigger
        // the fallback; ordinary unrelated imports must not.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "Library", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("Directory.Build.props", """
            <Project>
              <Import Project="Sdk.props" Sdk="Microsoft.DotNet.Arcade.Sdk" />
              <Import Project="NullablePolyfill.targets" />
              <Import Project="../Versions.props" />
              <PropertyGroup>
                <LangVersion>preview</LangVersion>
                <Nullable>enable</Nullable>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildPropsContainsImportButNoMarker_ReturnsTrue()
    {
        // Real-world shape from playground/Directory.Build.targets and
        // tests/Aspire.Hosting.TestUtilities/Directory.Build.props: the file chains to a shared parent
        // with <Import Project="$([MSBuild]::GetPathOfFileAbove(...))" />. We can't follow that import
        // statically (variable-based path, conditional Imports, SDK resolution), so an AppHost marker
        // could legally live in the imported file. Conservatively treat the project as a candidate and
        // let MSBuild be authoritative rather than silently rejecting a real AppHost.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "MyHost", "MyHost.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("Directory.Build.props", """
            <Project>
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Shared.props', '$(MSBuildThisFileDirectory)../'))" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildPropsContainsGetDirectoryNameOfFileAboveImport_ReturnsTrue()
    {
        // GetDirectoryNameOfFileAbove is the directory-returning sibling of GetPathOfFileAbove and is
        // equally tree-walking. Treat imports that use it the same way: we cannot resolve the target
        // file statically, so let MSBuild be authoritative.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "MyHost", "MyHost.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("Directory.Build.props", """
            <Project>
              <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)..', 'Shared.props'))/Shared.props" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildPropsUnreadable_ReturnsTrue()
    {
        // A relevant parent build file that exists but is malformed XML is still consumed by MSBuild
        // during evaluation, where it could declare the marker. Falling through to "no marker, return
        // false" would silently reject the project; conservatively keep it as a candidate so MSBuild's
        // evaluation surfaces the authoritative result.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "MyHost", "MyHost.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject("Directory.Build.props", "<Project");
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_MarkerAboveNestedGitBoundary_ReturnsTrue()
    {
        // Layout 1 (nested repo). MSBuild's Directory.Build.props/.targets discovery has no concept of a
        // .git boundary — it walks parent directories to the filesystem root (see
        // https://learn.microsoft.com/visualstudio/msbuild/customize-by-directory). So an Aspire AppHost
        // checked in inside a nested repository, git submodule, or worktree can legitimately inherit
        // <IsAspireHost>true</IsAspireHost> from a Directory.Build.props ABOVE its inner .git. The prefilter
        // must match MSBuild here and keep walking past .git; stopping at .git would silently reject a real
        // AppHost before MSBuild ever evaluates it. This mirrors a fixture evaluated with real MSBuild:
        //   outer/Directory.Build.props                       <IsAspireHost>true</IsAspireHost>
        //   outer/inner-repo/.git/                            (nested repo boundary)
        //   outer/inner-repo/MyAppHost/MyAppHost.csproj
        //   dotnet msbuild MyAppHost.csproj -getProperty:IsAspireHost  =>  true
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "inner-repo", "MyAppHost", "MyAppHost.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        // Nested repo boundary between the project and the marker directory above it, proving the walk no
        // longer stops at .git.
        Directory.CreateDirectory(Path.Combine(_workspace.WorkspaceRoot.FullName, "outer", "inner-repo", ".git"));
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ImportAppendsSiblingAfterGetDirectoryNameOfFileAboveDirectoryBuildPropsAnchor_ReturnsTrue()
    {
        // Layout 2 (anchor + appended sibling). GetDirectoryNameOfFileAbove returns the *directory* that
        // contains the anchor file, and the actually-imported project is the file appended after the call.
        // Here the anchor is 'Directory.Build.props' but the import target is the appended Custom.props,
        // which carries the marker — so the conventional-name skip must NOT be applied to the anchor. This
        // mirrors a fixture evaluated with real MSBuild:
        //   repo/Directory.Build.props                        (NO marker)
        //   repo/Custom.props                                 <IsAspireHost>true</IsAspireHost>
        //   repo/src/MyAppHost/MyAppHost.csproj imports
        //     $([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), 'Directory.Build.props'))\Custom.props
        //   dotnet msbuild MyAppHost.csproj -getProperty:IsAspireHost  =>  true
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("repo", "src", "MyAppHost", "MyAppHost.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), 'Directory.Build.props'))\Custom.props" />
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("repo", "Directory.Build.props"), """
            <Project>
              <!-- Deliberately contains NO Aspire marker. The marker lives in Custom.props next to it. -->
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("repo", "Custom.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ConventionalDirectoryBuildChainingWithoutMarker_ReturnsFalse()
    {
        // Layout 3 (conventional chaining, no marker). A src-level Directory.Build.props that only chains to
        // its parent via GetPathOfFileAbove('Directory.Build.props', ...) delivers no content the ancestor
        // walk cannot already see by name, and neither build file declares a marker. This is exactly the
        // "evaluation storm" shape the prefilter exists to filter out, so the widened walk-to-root and the
        // GetDirectoryNameOfFileAbove handling must NOT turn it into a candidate. Real MSBuild agrees:
        //   repo/Directory.Build.props                        (no marker, just LangVersion)
        //   repo/src/Directory.Build.props                    chains via GetPathOfFileAbove('Directory.Build.props', ...)
        //   repo/src/OrdinaryLib/OrdinaryLib.csproj
        //   dotnet msbuild OrdinaryLib.csproj -getProperty:IsAspireHost  =>  (empty)
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("repo", "src", "OrdinaryLib", "OrdinaryLib.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("repo", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <LangVersion>preview</LangVersion>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("repo", "src", "Directory.Build.props"), """
            <Project>
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NearestDirectoryBuildPropsWithoutChainShadowsOuterMarker_ReturnsFalse()
    {
        // MSBuild imports only the NEAREST Directory.Build.props. When that nearest file declares no marker
        // and does NOT chain to its parent, an outer Directory.Build.props marker is shadowed and never
        // evaluated, so the prefilter must return false — walking every ancestor and accepting the outer
        // marker would force MSBuild evaluation for every ordinary project below it. Verified with real
        // MSBuild:
        //   outer/Directory.Build.props            <IsAspireHost>true</IsAspireHost>
        //   outer/repo/Directory.Build.props       (no marker, no chain to parent)
        //   outer/repo/proj/OrdinaryLib.csproj
        //   dotnet msbuild OrdinaryLib.csproj -getProperty:IsAspireHost  =>  (empty)
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "proj", "OrdinaryLib.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NearestDirectoryBuildPropsChainsToOuterMarker_ReturnsTrue()
    {
        // Counterpart to the shadowing case: when the nearest Directory.Build.props explicitly chains to its
        // parent via the conventional GetPathOfFileAbove import, MSBuild keeps reading upward and DOES see
        // the outer marker, so the prefilter must return true. Verified with real MSBuild: adding the chain
        // import to the nested file flips -getProperty:IsAspireHost from empty to `true`.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "proj", "MyAppHost.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NearestDirectoryBuildPropsChainSkipsIntermediateToOuterMarker_ReturnsTrue()
    {
        // A chain whose start directory skips levels ($(MSBuildThisFileDirectory)../../) begins the
        // GetPathOfFileAbove search ABOVE the immediate parent, so MSBuild jumps over the intervening
        // marker-less Directory.Build.props and imports the marker two levels up. A nearest-file walk that
        // treats the skip as ordinary immediate-parent chaining would continue to that skipped intermediate,
        // see it stop, and wrongly return false. The prefilter must keep the project a candidate. Verified
        // with real MSBuild:
        //   outer/Directory.Build.props                 <IsAspireHost>true</IsAspireHost>
        //   outer/mid/Directory.Build.props             (no marker, no chain)
        //   outer/mid/inner/Directory.Build.props       chains via GetPathOfFileAbove(.., '$(MSBuildThisFileDirectory)../../')
        //   outer/mid/inner/proj/OrdinaryLib.csproj
        //   dotnet msbuild OrdinaryLib.csproj -getProperty:IsAspireHost  =>  true
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "mid", "inner", "proj", "OrdinaryLib.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "mid", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "mid", "inner", "Directory.Build.props"), """
            <Project>
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../../'))" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ImmediateParentChainToEmptyParentShadowingGrandparentMarker_ReturnsFalse()
    {
        // Guard for the level-skip fix: the ordinary immediate-parent chain ($(MSBuildThisFileDirectory)../)
        // must still be honored as chaining exactly one level. Here the nearest file chains to its immediate
        // parent, but that parent is marker-less and does not chain further, so MSBuild's search stops there
        // and never reaches the grandparent marker. The prefilter must return false rather than promoting the
        // project on a marker MSBuild cannot see. Verified with real MSBuild:
        //   outer/Directory.Build.props                 <IsAspireHost>true</IsAspireHost>
        //   outer/mid/Directory.Build.props             (no marker, no chain)
        //   outer/mid/inner/Directory.Build.props       chains via GetPathOfFileAbove(.., '$(MSBuildThisFileDirectory)../')
        //   outer/mid/inner/proj/OrdinaryLib.csproj
        //   dotnet msbuild OrdinaryLib.csproj -getProperty:IsAspireHost  =>  (empty)
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "mid", "inner", "proj", "OrdinaryLib.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "mid", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "mid", "inner", "Directory.Build.props"), """
            <Project>
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NearestDirectoryBuildPropsStaticallyImportsParentMarker_ReturnsTrue()
    {
        // A nested Directory.Build.props can also chain to its parent with an ordinary static import
        // (<Import Project="../Directory.Build.props" />) rather than the GetPathOfFileAbove helper. Verified
        // with real MSBuild: that static chain makes the outer <IsAspireHost>true</IsAspireHost> visible
        // (-getProperty:IsAspireHost => true), so the prefilter must treat this as a candidate and not stop
        // at the nearest file.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "proj", "MyAppHost.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="../Directory.Build.props" />
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NearestDirectoryBuildPropsImportsDifferentlyCasedParentMarker_ReturnsTrue()
    {
        // The explicit import is valid on case-sensitive systems when the differently-cased file exists,
        // and resolves to the conventional parent file on Windows. Treating it as unrelated can reject a
        // marker that MSBuild evaluates on either platform.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "proj", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "directory.build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="../directory.build.props" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_DirectoryBuildPropsCrossNameWalkUpBypassesNearestTargets_ReturnsTrue()
    {
        // The props import starts at its parent and explicitly reaches the outer targets file. The normal
        // targets walk cannot stand in for that import because a nearer marker-less targets file shadows it.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "proj", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.targets"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)../'))" />
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.targets"), """
            <Project>
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_DirectoryBuildPropsCrossNameStaticImportBypassesNearestTargets_ReturnsTrue()
    {
        // An explicit props-to-targets import resolves independently of the automatic targets search. A
        // nearer marker-less targets file can shadow the automatic search without shadowing this import.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "proj", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.targets"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="../Directory.Build.targets" />
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.targets"), """
            <Project>
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_DirectoryBuildPropsCrossNameSiblingImportBypassesNearerTargets_ReturnsTrue()
    {
        // The props file explicitly imports its sibling targets file. A targets file nearer to the project
        // shadows only the automatic targets search, not this explicit import.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "proj", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="Directory.Build.targets" />
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.targets"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "proj", "Directory.Build.targets"), """
            <Project>
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NearestDirectoryBuildPropsWildcardImportsParentMarker_ReturnsTrue()
    {
        // Wildcards can resolve to a conventional parent build file. Treating the literal pattern as an
        // unrelated filename rejects a marker that MSBuild imports.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "proj", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="../Directory.Build.p*" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NearestDirectoryBuildPropsImportListIncludesParentMarker_ReturnsTrue()
    {
        // MSBuild evaluates each semicolon-delimited Project entry. A later unrelated import must not hide
        // the parent-chain entry from the prefilter.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "proj", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="../Directory.Build.props;Shared.props" />
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Shared.props"), "<Project />");
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NearestDirectoryBuildTargetsMarkerNotShadowedByNonChainingProps_ReturnsTrue()
    {
        // props and targets are resolved independently by MSBuild. A nearest Directory.Build.props that does
        // not chain shadows only outer *props*; a marker in the nearest Directory.Build.targets is still
        // imported. The prefilter must search the two file names independently and not let a non-chaining
        // props file suppress a real targets marker.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "proj", "MyHost.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.targets"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ConventionalChainImportFromExternalStartDirectory_ReturnsTrue()
    {
        // The conventional-name shortcut (skipping GetPathOfFileAbove('Directory.Build.props', ...) as
        // "already enumerated by the ancestor walk") is only valid when the walk-up starts inside the
        // importing file's own ancestor chain. GetPathOfFileAbove accepts an ARBITRARY starting directory,
        // so a walk-up rooted at an external directory resolves a Directory.Build.props this walk never
        // inspects. Verified with real MSBuild: such an import DOES pull in the external file
        // (-getProperty:IsAspireHost => true), so applying the shortcut here would be a false negative. The
        // absolute external start makes it out-of-tree, so the project must stay a candidate.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("proj", "MyApp.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
        var externalAnchor = Path.Combine(_workspace.WorkspaceRoot.FullName, "external", "anchor");
        Directory.CreateDirectory(externalAnchor);
        WriteIsLikelyAppHostProject(Path.Combine("proj", "Directory.Build.props"), $"""
            <Project>
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '{externalAnchor.Replace("\\", "\\\\")}'))" />
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("external", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ImportProjectPathContainsCallShapedHelperNameInLiteralPath_ReturnsFalse()
    {
        // Even a literal path that happens to contain "GetPathOfFileAbove(...)" as text — not an
        // MSBuild property function call — must not trigger the walk-up fallback. Only the full
        // $([MSBuild]::GetPathOfFileAbove(...)) shape is an actual call MSBuild will evaluate.
        // Without this strictness, an author whose static import path happens to wrap the helper
        // name in parentheses over-promotes their ordinary project.
        var projectFile = WriteIsLikelyAppHostProject("Library.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="build/GetPathOfFileAbove('Shared.props').props" />
              <Import Project="../getdirectorynameoffileabove(arg).targets" />
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_MalformedProjectXmlWithAncestorIsAspireHostMarker_ReturnsTrue()
    {
        // When the project's own XML can't be parsed, the cheap pre-check must still consult ancestor
        // Directory.Build.* files before falling back to the name heuristic. An ordinary-named broken
        // project whose ancestors declare the AppHost marker is exactly the case where MSBuild would still
        // try to evaluate the project and surface a "possibly unbuildable" warning, so rejecting it here on
        // a name miss would silently drop a real AppHost.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "MyHost", "MyHost.csproj"), "<Project Sdk=\"Microsoft.NET.Sdk\"");
        WriteIsLikelyAppHostProject("Directory.Build.props", """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_MalformedProjectXmlWithAncestorAppHostSdkImport_ReturnsTrue()
    {
        // Companion to the ancestor-IsAspireHost malformed-project test: an ancestor that imports
        // Aspire.AppHost.Sdk must also keep a broken descendant project flowing to MSBuild as a
        // candidate rather than being silently rejected on a name miss.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "MyHost", "MyHost.csproj"), "<Project Sdk=\"Microsoft.NET.Sdk\"");
        WriteIsLikelyAppHostProject("Directory.Build.targets", """
            <Project>
              <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildPropsUnquotedConventionalChaining_ReturnsFalse()
    {
        // MSBuild property-function arguments accept unquoted scalar forms in addition to single-
        // or double-quoted strings. Unquoted conventional chaining like
        //   $([MSBuild]::GetPathOfFileAbove(Directory.Build.props, $(MSBuildThisFileDirectory)../))
        // points at a file the ancestor walk already enumerates by name, so the chain delivers
        // nothing new — it must not be treated as uncertain just because the file name is unquoted.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "Library", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("src", "Directory.Build.props"), """
            <Project>
              <Import Project="$([MSBuild]::GetPathOfFileAbove(Directory.Build.props, $(MSBuildThisFileDirectory)../))" />
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildPropsDifferentlyCasedConventionalChaining_ReturnsTrue()
    {
        // On case-sensitive filesystems, GetPathOfFileAbove('directory.build.props', ...) can resolve
        // a different file than the exact Directory.Build.props name the ancestor walk probes. Treat
        // the chain as uncertain so MSBuild can decide rather than silently rejecting a real AppHost.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "Library", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("src", "Directory.Build.props"), """
            <Project>
              <Import Project="$([MSBuild]::GetPathOfFileAbove('directory.build.props', '$(MSBuildThisFileDirectory)../'))" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildPropsUnquotedNonConventionalImport_ReturnsTrue()
    {
        // Symmetric positive for unquoted args: an unquoted non-conventional target (Shared.props)
        // still triggers the conservative fallback because the ancestor walk does not enumerate
        // arbitrarily-named shared files.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "Library", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("src", "Directory.Build.props"), """
            <Project>
              <Import Project="$([MSBuild]::GetPathOfFileAbove(Shared.props, $(MSBuildThisFileDirectory)../))" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorDirectoryBuildPropsUnquotedDirectoryPackagesChaining_ReturnsTrue()
    {
        // Locks in the exact real-world shape from this repo's tests/Directory.Packages.props:2:
        //   <Import Project="$([MSBuild]::GetPathOfFileAbove(Directory.Packages.props, $(MSBuildThisFileDirectory)..))" />
        // The unquoted file name is Directory.Packages.props, which is NOT one of the conventional
        // Directory.Build.* files the ancestor walk enumerates. Even though the file looks
        // "infrastructure-like", the prefilter cannot read it, so an AppHost marker could legally
        // live there — fall back to MSBuild evaluation.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "Library", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("src", "Directory.Build.props"), """
            <Project>
              <Import Project="$([MSBuild]::GetPathOfFileAbove(Directory.Packages.props, $(MSBuildThisFileDirectory)..))" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorImportAppendsCustomFileAfterGetDirectoryNameOfFileAboveAnchor_ReturnsTrue()
    {
        // GetDirectoryNameOfFileAbove returns a *directory* (the directory containing the named
        // file). A real import that uses it concatenates a file name onto that directory, e.g.
        //   $([MSBuild]::GetDirectoryNameOfFileAbove(.., 'Directory.Build.props'))/Custom.props
        // The actual imported file is the appended segment — here Custom.props — which the
        // ancestor walk does NOT enumerate by name. Looking only at the second function argument
        // (Directory.Build.props) and skipping the project would silently reject a real chain
        // pulling in arbitrary shared content.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("src", "Library", "Library.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("src", "Directory.Build.props"), """
            <Project>
              <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)..', 'Directory.Build.props'))/Custom.props" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ProjectFileImportAppendsNonConventionalFileAfterGetDirectoryNameOfFileAbove_ReturnsTrue()
    {
        // Project-file variant of the suffix-aware blocker: the same
        //   $([MSBuild]::GetDirectoryNameOfFileAbove(..., 'Directory.Build.props'))/Custom.props
        // shape can appear directly in a normal-named csproj. The captured second argument is
        // conventional, but the actual imported file is the appended Custom.props, which the
        // ancestor walk does not enumerate. Without suffix awareness the project is silently
        // rejected before MSBuild evaluation — isolated here from the ancestor-walk path so a
        // future refactor that breaks suffix extraction surfaces a clear, project-file-level
        // failure rather than only being caught at the ancestor level.
        var projectFile = WriteIsLikelyAppHostProject("Library.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)..', 'Directory.Build.props'))/Custom.props" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NearestDirectoryBuildPropsChainsToParentMarkerViaThisFileDirectoryProperty_ReturnsTrue()
    {
        // The parent-chain import is commonly written with the $(MSBuildThisFileDirectory) reserved property
        // instead of a bare relative path:
        //   <Import Project="$(MSBuildThisFileDirectory)..\Directory.Build.props" />
        // MSBuild expands that property to the directory of the file containing the import (with a trailing
        // slash), so the target is the immediate parent's Directory.Build.props — identical in effect to
        // <Import Project="../Directory.Build.props" />, which this suite already covers. Treating every
        // property-expanded import as inert reported StopsHere here and rejected a project whose outer
        // <IsAspireHost>true</IsAspireHost> MSBuild really does see:
        //   outer/Directory.Build.props            <IsAspireHost>true</IsAspireHost>
        //   outer/repo/Directory.Build.props       chains via $(MSBuildThisFileDirectory)..\Directory.Build.props
        //   outer/repo/proj/OrdinaryLib.csproj
        //   dotnet msbuild OrdinaryLib.csproj -getProperty:IsAspireHost  =>  true
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "proj", "OrdinaryLib.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="$(MSBuildThisFileDirectory)..\Directory.Build.props" />
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NearestDirectoryBuildPropsStaticImportSkipsLevelToOuterMarker_ReturnsTrue()
    {
        // Static counterpart to the level-skipping GetPathOfFileAbove case: an import that reaches two
        // levels up imports the grandparent file directly, jumping over the intervening marker-less
        // Directory.Build.props that this walk would visit next. Stopping at that intermediate would reject
        // a project whose marker MSBuild imports, so the level-skipping shape must stay a candidate:
        //   outer/Directory.Build.props                 <IsAspireHost>true</IsAspireHost>
        //   outer/mid/Directory.Build.props             (no marker, no chain)
        //   outer/mid/inner/Directory.Build.props       imports $(MSBuildThisFileDirectory)../../Directory.Build.props
        //   outer/mid/inner/proj/OrdinaryLib.csproj
        //   dotnet msbuild OrdinaryLib.csproj -getProperty:IsAspireHost  =>  true
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "mid", "inner", "proj", "OrdinaryLib.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "mid", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "mid", "inner", "Directory.Build.props"), """
            <Project>
              <Import Project="$(MSBuildThisFileDirectory)../../Directory.Build.props" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NearestDirectoryBuildPropsImportsConventionalNameFromUnknownProperty_ReturnsTrue()
    {
        // <Import Project="$(RepoRoot)Directory.Build.props" /> lands on a conventional file, but only
        // MSBuild knows which directory $(RepoRoot) points at — it can easily be an ancestor whose marker
        // the nearest-file walk considers shadowed. Classify it conservatively instead of stopping the
        // chain, otherwise a real AppHost is rejected before MSBuild ever runs.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("repo", "proj", "OrdinaryLib.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("repo", "Directory.Build.props"), """
            <Project>
              <Import Project="$(RepoRoot)Directory.Build.props" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_NearestDirectoryBuildPropsImportsUnrelatedFileFromUnknownProperty_ReturnsFalse()
    {
        // Guard against over-correcting the property-expanded import fix. The exact shapes below are lifted
        // from this repo's own root Directory.Build.targets and tests/Directory.Build.props. They name
        // ordinary shared infrastructure, so they must stay inert — promoting every project that sits under
        // a $(Prop)-based infrastructure import would defeat the prefilter in essentially every .NET repo.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("repo", "proj", "OrdinaryLib.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("repo", "Directory.Build.props"), """
            <Project>
              <Import Project="$(RepositoryEngineeringDir)/NullablePolyfill.targets" />
              <Import Project="$(TestsSharedRepoTestingDir)Aspire.RepoTesting.props" />
              <Import Project="$(MSBuildThisFileDirectory)eng/Common.props" />
            </Project>
            """);
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_AncestorImportsDirectoryBuildPropsFromCustomAnchorDirectory_ReturnsTrue()
    {
        // GetDirectoryNameOfFileAbove(start, anchor) is selected by its ANCHOR argument: the call returns the
        // directory containing the nearest 'Repo.marker' at or above the start, and the appended
        // '/Directory.Build.props' names the file imported FROM that directory. The anchor can therefore
        // reach PAST a nearer marker-less Directory.Build.props that shadows the ordinary chain. Treating the
        // appended conventional name as "the next file the ancestor walk visits" made the walk advance one
        // level, stop at that intervening file, and reject the project:
        //   outer/Repo.marker                          (anchor)
        //   outer/Directory.Build.props                <IsAspireHost>true</IsAspireHost>
        //   outer/mid/Directory.Build.props            (no marker, no chain — shadows outer for the walk)
        //   outer/mid/repo/Directory.Build.props       imports GetDirectoryNameOfFileAbove(.., 'Repo.marker')/Directory.Build.props
        //   outer/mid/repo/proj/OrdinaryLib.csproj
        //   dotnet msbuild OrdinaryLib.csproj -getProperty:IsAspireHost  =>  true
        // The intervening outer/mid file is what makes this fail without the fix: with it, following the
        // appended name lands on a marker-less file and the walk stops, while MSBuild jumps straight to the
        // anchor-bearing directory two levels up.
        var projectFile = WriteIsLikelyAppHostProject(Path.Combine("outer", "mid", "repo", "proj", "OrdinaryLib.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Repo.marker"), "<Project />");
        WriteIsLikelyAppHostProject(Path.Combine("outer", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "mid", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
        WriteIsLikelyAppHostProject(Path.Combine("outer", "mid", "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)../', 'Repo.marker'))/Directory.Build.props" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ProjectFileImportsDirectoryBuildPropsFromCustomAnchorDirectory_ReturnsTrue()
    {
        // Project-file half of the anchor-vs-target fix. The conventional-name shortcut also runs over a
        // .csproj's own imports, where the same GetDirectoryNameOfFileAbove shape can pull in a
        // Directory.Build.props from an arbitrary anchor-bearing ancestor. Isolated from the ancestor-walk
        // test so a regression in either code path fails on its own.
        var projectFile = WriteIsLikelyAppHostProject("Library.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)../', 'Repo.marker'))/Directory.Build.props" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Fact]
    public void IsLikelyAppHost_ProjectFileAppendsUnresolvableSuffixToConventionalWalkUp_ReturnsTrue()
    {
        // Same anchor-vs-target confusion, reached the other way round: the call names a conventional file,
        // but path text is appended to it, so the anchor is no longer the imported file. The appended
        // segment is an MSBuild expression, so the real target is unknowable and the project must stay a
        // candidate rather than being skipped on the anchor's conventional name.
        var projectFile = WriteIsLikelyAppHostProject("Library.csproj", """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))/../$(SharedPropsName).props" />
            </Project>
            """);
 
        Assert.True(DotNetAppHostProject.IsLikelyAppHost(projectFile));
    }
 
    [Theory]
    [InlineData("src/Aspire.Hosting/Aspire.Hosting.csproj")]
    [InlineData("src/Aspire.Cli/Aspire.Cli.csproj")]
    [InlineData("src/Aspire.Hosting.Yarp/Aspire.Hosting.Yarp.csproj")]
    [InlineData("src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj")]
    [InlineData("src/Aspire.Dashboard/Aspire.Dashboard.csproj")]
    public void IsLikelyAppHost_OrdinaryLibraryCsprojUnderSrc_ReturnsFalse(string relativePath)
    {
        // These ordinary library projects sit under repo-level Directory.Build.* files that chain to
        // parent build files. The conventional-chaining bypass and function-call-shape narrowing
        // keep those csprojs classified as not-likely-AppHost. Probing real repo files rather than
        // only synthetic shapes guards against future regressions where a refactor of
        // ContainsDynamicWalkUpImport accidentally re-broadens the fallback.
        var repoRoot = GetRepoRoot();
        if (repoRoot is null)
        {
            // Tests can run from a packaged install where the repo layout isn't present. Skip
            // rather than fail; the unit-test shapes above already lock in the algorithmic behavior.
            return;
        }
 
        var projectFile = new FileInfo(Path.Combine(repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
        if (!projectFile.Exists)
        {
            // A repo refactor moved the probed csproj. Don't fail the suite on file relocation —
            // the algorithmic coverage above still asserts the narrowing rules.
            return;
        }
 
        Assert.False(DotNetAppHostProject.IsLikelyAppHost(projectFile),
            $"{relativePath} is an ordinary library and must not classify as a likely AppHost.");
    }
 
    private static string? GetRepoRoot()
    {
        // Walk up from the test assembly directory until we find Aspire.slnx (the canonical repo
        // root marker, also used by AspireRepositoryDetector). Return null if we hit the filesystem
        // root without finding it, which happens when these tests run from a packaged install.
        var dir = new DirectoryInfo(AppContext.BaseDirectory);
        while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Aspire.slnx")))
        {
            dir = dir.Parent;
        }
        return dir?.FullName;
    }
 
    private FileInfo WriteIsLikelyAppHostProject(string fileName, string content)
    {
        // Isolation note: the ancestor walk mirrors MSBuild and walks to the filesystem root with no .git
        // boundary, so these tests rely on each temp workspace being a unique, randomly-named directory
        // with no Directory.Build.* marker anywhere above it. A stray marker above the OS temp directory
        // could flip the _ReturnsFalse assertions; in practice temp roots contain no such files. Sibling
        // workspaces are not ancestors of one another, so per-test markers never leak across tests. The
        // .git sentinel stamped here provides a realistic nested-repo layout but does not bound the walk —
        // tests that need a marker found above a nested .git deliberately place it there.
        Directory.CreateDirectory(Path.Combine(_workspace.WorkspaceRoot.FullName, ".git"));
 
        var path = Path.Combine(_workspace.WorkspaceRoot.FullName, fileName);
        var directory = Path.GetDirectoryName(path);
        if (!string.IsNullOrEmpty(directory))
        {
            Directory.CreateDirectory(directory);
        }
        File.WriteAllText(path, content);
        return new FileInfo(path);
    }
 
    private static JsonDocument CreateAppHostInfoJson(
        string? runCommand,
        string? targetPath = null,
        string? runWorkingDirectory = null,
        string? runArguments = null,
        string? targetFrameworks = null)
    {
        var properties = new Dictionary<string, string?>
        {
            ["MSBuildVersion"] = "17.0.0",
            ["IsAspireHost"] = "true",
            ["AspireHostingSDKVersion"] = VersionHelper.GetDefaultTemplateVersion(),
            ["RunCommand"] = runCommand,
            ["TargetPath"] = targetPath,
            ["RunWorkingDirectory"] = runWorkingDirectory,
            ["RunArguments"] = runArguments,
            ["TargetFramework"] = "net10.0",
            ["TargetFrameworks"] = targetFrameworks
        };
 
        return JsonDocument.Parse(JsonSerializer.Serialize(new { Properties = properties, Items = new { } }));
    }
 
    private FileInfo CreateProjectAppHost(string fileName = "AppHost.csproj")
    {
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, fileName);
        File.WriteAllText(appHostPath, """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
 
        return new FileInfo(appHostPath);
    }
 
    private FileInfo CreateOrdinaryProject(string fileName = "Library.csproj")
    {
        // A plain SDK-style library: no AppHost markers, no imports, and a non-AppHost name.
        // This is exactly the shape DotNetAppHostProject.IsLikelyAppHost returns false for, so the
        // cheap heuristic short-circuits validation before MSBuild evaluation runs.
        var projectPath = Path.Combine(_workspace.WorkspaceRoot.FullName, fileName);
        File.WriteAllText(projectPath, """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
 
        return new FileInfo(projectPath);
    }
 
    private DirectoryInfo CreateCliBundle(out LayoutConfiguration layout)
    {
        var bundleRoot = Directory.CreateDirectory(Path.Combine(_workspace.WorkspaceRoot.FullName, Guid.NewGuid().ToString()));
        var dcpDirectory = Directory.CreateDirectory(Path.Combine(bundleRoot.FullName, BundleDiscovery.DcpDirectoryName));
        var managedDirectory = Directory.CreateDirectory(Path.Combine(bundleRoot.FullName, BundleDiscovery.ManagedDirectoryName));
        File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(dcpDirectory.FullName), "");
        File.WriteAllText(
            Path.Combine(managedDirectory.FullName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)),
            "");
 
        layout = new LayoutConfiguration
        {
            LayoutPath = bundleRoot.FullName,
            Components = new LayoutComponents
            {
                Dcp = BundleDiscovery.DcpDirectoryName,
                Managed = BundleDiscovery.ManagedDirectoryName,
            }
        };
 
        return bundleRoot;
    }
 
    private DotNetAppHostProject CreateDotNetAppHostProject(
        TestDotNetCliRunner runner,
        LayoutConfiguration? layout = null,
        IAppHostInfoResolver? appHostInfoResolver = null,
        Action<CliServiceCollectionTestOptions>? configureServices = null,
        IEnvironment? environment = null)
    {
        var services = CliTestHelper.CreateServiceCollection(_workspace, outputHelper, options =>
        {
            options.DotNetCliRunnerFactory = _ => runner;
            if (layout is not null)
            {
                options.BundleServiceFactory = _ => new TestBundleService(isBundle: true)
                {
                    Layout = layout
                };
            }
 
            configureServices?.Invoke(options);
        });
 
        if (environment is not null)
        {
            services.RemoveAll<IEnvironment>();
            services.AddSingleton(environment);
        }
 
        if (appHostInfoResolver is not null)
        {
            services.RemoveAll<IAppHostInfoResolver>();
            services.AddSingleton(appHostInfoResolver);
        }
 
        var provider = services.BuildServiceProvider();
        _serviceProviders.Add(provider);
        return provider.GetRequiredService<DotNetAppHostProject>();
    }
 
    private static void WriteAspireConfigJson(string directory, string content)
        => File.WriteAllText(Path.Combine(directory, "aspire.config.json"), content);
 
    private sealed class TestAppHostInfoResolver : IAppHostInfoResolver
    {
        public Func<FileInfo, CancellationToken, Task<AppHostProjectInfo>>? GetAppHostInfoAsyncCallback { get; init; }
 
        public int CallCount { get; private set; }
 
        public Task<AppHostProjectInfo> GetAppHostInfoAsync(FileInfo projectFile, CancellationToken cancellationToken)
        {
            CallCount++;
 
            if (GetAppHostInfoAsyncCallback is not null)
            {
                return GetAppHostInfoAsyncCallback(projectFile, cancellationToken);
            }
 
            throw new InvalidOperationException("GetAppHostInfoAsync should not be called in this test.");
        }
    }
}