File: MSBuildTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
using Aspire.Hosting.Tests.Utils;
using Aspire.Shared;
 
namespace Aspire.Hosting.Tests;
 
[Trait("Partition", "6")]
public class MSBuildTests(ITestOutputHelper outputHelper)
{
    /// <summary>
    /// Tests that when an AppHost has a ProjectReference to a library project, a warning is emitted.
    /// </summary>
    [Fact]
    public void EnsureWarningsAreEmittedWhenProjectReferencingLibraries()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        CreateLibraryProject(workspace.WorkspaceRoot.FullName, "Library");
 
        var appHostDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost");
        Directory.CreateDirectory(appHostDirectory);
 
        File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.csproj"),
            $"""
            <Project Sdk="Microsoft.NET.Sdk">
 
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net8.0</TargetFramework>
                <ImplicitUsings>enable</ImplicitUsings>
                <Nullable>enable</Nullable>
                <IsAspireHost>true</IsAspireHost>
 
                <!--
                  Test applications have their own way of referencing Aspire.Hosting.AppHost, as well as DCP and Dashboard, so we disable
                  the Aspire.AppHost.SDK targets that will automatically add these references to projects.
                -->
                <SkipAddAspireDefaultReferences Condition="'$(TestsRunningOutsideOfRepo)' != 'true'">true</SkipAddAspireDefaultReferences>
                <AspireHostingSDKVersion>9.0.0</AspireHostingSDKVersion>
                <_AspireUseTaskHostFactory>true</_AspireUseTaskHostFactory>
              </PropertyGroup>
 
              <ItemGroup>
                <ProjectReference Include="{repoRoot}\src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj" IsAspireProjectResource="false" />
 
                <ProjectReference Include="..\Library\Library.csproj" />
              </ItemGroup>
 
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.cs"),
            """
            var builder = DistributedApplication.CreateBuilder();
            builder.Build().Run();
            """);
 
        CreateDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        var output = BuildProject(appHostDirectory);
 
        // Ensure a warning is emitted when an AppHost references a Library project
        Assert.Contains("warning ASPIRE004", output);
    }
 
    /// <summary>
    /// Tests that the metadata sources are emitted correctly.
    /// </summary>
    [Fact]
    public async Task ValidateMetadataSources()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        CreateAppProject(workspace.WorkspaceRoot.FullName, "App");
 
        var appHostDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost");
        Directory.CreateDirectory(appHostDirectory);
 
        File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.csproj"),
            $"""
            <Project Sdk="Microsoft.NET.Sdk">
 
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net8.0</TargetFramework>
                <ImplicitUsings>enable</ImplicitUsings>
                <Nullable>enable</Nullable>
                <IsAspireHost>true</IsAspireHost>
 
                <!--
                  Test applications have their own way of referencing Aspire.Hosting.AppHost, as well as DCP and Dashboard, so we disable
                  the Aspire.AppHost.SDK targets that will automatically add these references to projects.
                -->
                <SkipAddAspireDefaultReferences Condition="'$(TestsRunningOutsideOfRepo)' != 'true'">true</SkipAddAspireDefaultReferences>
                <AspireHostingSDKVersion>9.0.0</AspireHostingSDKVersion>
                <_AspireUseTaskHostFactory>true</_AspireUseTaskHostFactory>
              </PropertyGroup>
 
              <ItemGroup>
                <ProjectReference Include="{repoRoot}\src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj" IsAspireProjectResource="false" />
                <ProjectReference Include="..\App\App.csproj" />
              </ItemGroup>
 
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.cs"),
            """
            var builder = DistributedApplication.CreateBuilder();
            builder.Build().Run();
            """);
 
        CreateDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        var output = BuildProject(appHostDirectory);
 
        var metadataDirectory = Path.Combine(appHostDirectory, "obj", "Debug", "net8.0", "Aspire", "references");
        var appHostMetadata = await File.ReadAllTextAsync(Path.Combine(metadataDirectory, "_AppHost.ProjectMetadata.g.cs"));
        var appMetadata = await File.ReadAllTextAsync(Path.Combine(metadataDirectory, "App.ProjectMetadata.g.cs"));
 
        await Verify(new
        {
            AppHost = appHostMetadata,
            App = appMetadata
        }).ScrubLinesWithReplace(line =>
            {
                var temp = workspace?.WorkspaceRoot.FullName;
                if (temp is not null)
                {
                    line = line.Replace($"/private{temp}", "{AspirePath}") // Handle macOS temp symlinks
                               .Replace(temp, "{AspirePath}")
                               .Replace(Path.DirectorySeparatorChar, '/');
                }
                return line;
            });
    }
 
    private static void CreateDirectoryBuildFiles(string basePath, string repoRoot)
    {
#if DEBUG
        var config = "Debug";
#else
        var config = "Release";
#endif
 
        File.WriteAllText(Path.Combine(basePath, "Directory.Build.props"),
        $"""
        <Project>
          <PropertyGroup>
            <SkipAspireWorkloadManifest>true</SkipAspireWorkloadManifest>
            <AspireUseCliBundle>false</AspireUseCliBundle>
            <NoWarn>$(NoWarn);ASPIRE010</NoWarn>
          </PropertyGroup>
 
          <Import Project="{repoRoot}\src\Aspire.Hosting.AppHost\build\Aspire.Hosting.AppHost.props" />
        </Project>
        """);
        File.WriteAllText(Path.Combine(basePath, "Directory.Build.targets"),
        $"""
        <Project>
          <PropertyGroup>
            <_AspireTasksAssembly>{repoRoot}\artifacts\bin\Aspire.Hosting.Tasks\{config}\net8.0\Aspire.Hosting.Tasks.dll</_AspireTasksAssembly>
          </PropertyGroup>
 
          <Import Project="{repoRoot}\src\Aspire.Hosting.AppHost\build\Aspire.Hosting.AppHost.in.targets" />
          <Import Project="{repoRoot}\src\Aspire.AppHost.Sdk\SDK\Sdk.in.targets" />
        </Project>
        """);
    }
 
    private static void CreateLibraryProject(string basePath, string name)
    {
        var libraryDirectory = Path.Combine(basePath,  name);
        Directory.CreateDirectory(libraryDirectory);
 
        File.WriteAllText(Path.Combine(libraryDirectory, $"{name}.csproj"),
            """
            <Project Sdk="Microsoft.NET.Sdk">
 
              <PropertyGroup>
                <TargetFramework>net8.0</TargetFramework>
                <ImplicitUsings>enable</ImplicitUsings>
                <Nullable>enable</Nullable>
              </PropertyGroup>
 
            </Project>
            """);
        File.WriteAllText(Path.Combine(libraryDirectory, "Class1.cs"),
            """
            namespace Library;
 
            public class Class1
            {
            }
            """);
    }
 
    private static void CreateAppProject(string basePath, string name)
    {
        var appDirectory = Path.Combine(basePath, name);
        Directory.CreateDirectory(appDirectory);
 
        File.WriteAllText(Path.Combine(appDirectory, $"{name}.csproj"),
            """
            <Project Sdk="Microsoft.NET.Sdk.Web">
              <PropertyGroup>
                <TargetFramework>net8.0</TargetFramework>
                <ImplicitUsings>enable</ImplicitUsings>
                <Nullable>enable</Nullable>
              </PropertyGroup>
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(appDirectory, "Program.cs"),
            """
            Console.WriteLine("Hello, Aspire!");
            """);
    }
 
    private static string BuildProject(string workingDirectory, IDictionary<string, string>? environment = null)
    {
        var result = BuildProjectCore(workingDirectory, environment);
 
        Assert.True(result.ExitCode == 0, $"Build failed: {Environment.NewLine}{result.Output}");
 
        return result.Output;
    }
 
    private static string BuildProjectWithFailure(string workingDirectory, IDictionary<string, string>? environment = null)
    {
        var result = BuildProjectCore(workingDirectory, environment);
 
        Assert.NotEqual(0, result.ExitCode);
 
        return result.Output;
    }
 
    private static (int ExitCode, string Output) BuildProjectCore(string workingDirectory, IDictionary<string, string>? environment = null)
    {
        return RunDotNet(workingDirectory, "build --disable-build-servers", timeoutMilliseconds: 180_000, environment);
    }
 
    private static string PackProject(string projectFile, string outputDirectory, string packageId)
    {
        var workingDirectory = Path.GetDirectoryName(projectFile);
        Assert.NotNull(workingDirectory);
 
        var result = RunDotNet(
            workingDirectory,
            $"pack \"{projectFile}\" --disable-build-servers -o \"{outputDirectory}\"",
            timeoutMilliseconds: 300_000);
 
        Assert.True(result.ExitCode == 0, $"Pack failed: {Environment.NewLine}{result.Output}");
 
        return Directory.GetFiles(outputDirectory, $"{packageId}.*.nupkg")
            .Single(path => !path.EndsWith(".snupkg", StringComparison.Ordinal));
    }
 
    private static string ReadPackageVersion(string packagePath)
    {
        using var archive = ZipFile.OpenRead(packagePath);
        var nuspecEntry = archive.Entries.Single(entry => entry.FullName.EndsWith(".nuspec", StringComparison.Ordinal));
        using var stream = nuspecEntry.Open();
        var document = XDocument.Load(stream);
        var version = document.Root?
            .Descendants()
            .FirstOrDefault(element => element.Name.LocalName == "version")?
            .Value;
 
        Assert.False(string.IsNullOrEmpty(version), $"Could not determine package version from {packagePath}.");
 
        return version!;
    }
 
    private static (int ExitCode, string Output) RunDotNet(string workingDirectory, string arguments, int timeoutMilliseconds, IDictionary<string, string>? environment = null)
    {
        var output = new StringBuilder();
        var outputDone = new ManualResetEvent(false);
        using var process = new Process();
        // set '--disable-build-servers' so the MSBuild and Roslyn server processes don't hang around, which may hang the test in CI
        process.StartInfo = new ProcessStartInfo("dotnet", arguments)
        {
            RedirectStandardOutput = true,
            UseShellExecute = false,
            CreateNoWindow = true,
            WorkingDirectory = workingDirectory
        };
        if (environment is not null)
        {
            foreach (var (key, value) in environment)
            {
                process.StartInfo.Environment[key] = value;
            }
        }
        process.OutputDataReceived += (sender, e) =>
        {
            if (e.Data == null)
            {
                outputDone.Set();
            }
            else
            {
                output.AppendLine(e.Data);
            }
        };
        process.Start();
        process.BeginOutputReadLine();
 
        Assert.True(process.WaitForExit(milliseconds: timeoutMilliseconds), $"dotnet {arguments} command timed out.");
        Assert.True(outputDone.WaitOne(millisecondsTimeout: 60_000), "Timed out waiting for output to complete.");
 
        return (process.ExitCode, output.ToString());
    }
 
    [Fact]
    public void AppHostInspectionQuerySucceedsForPlainAppHostNamedProject()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var projectPath = Path.Combine(workspace.WorkspaceRoot.FullName, "Plain.AppHost.csproj");
        File.WriteAllText(projectPath, """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net8.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
 
        var result = RunDotNet(
            workspace.WorkspaceRoot.FullName,
            $"msbuild -getProperty:MSBuildVersion,IsAspireHost,AspireHostingSDKVersion,AspireUseCliBundle,UserSecretsId,RunCommand,TargetPath,RunWorkingDirectory,RunArguments,TargetFramework,TargetFrameworks -getItem:PackageReference,AspireProjectOrPackageReference,PackageVersion -t:ComputeRunArguments \"{projectPath}\"",
            timeoutMilliseconds: 180_000,
            new Dictionary<string, string>
            {
                ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1",
                ["DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE"] = "1",
            });
 
        Assert.True(result.ExitCode == 0, $"MSBuild query failed: {Environment.NewLine}{result.Output}");
        using var document = JsonDocument.Parse(result.Output);
        Assert.Equal(string.Empty, document.RootElement.GetProperty("Properties").GetProperty("IsAspireHost").GetString());
    }
 
    [Fact]
    public async Task CliBundleOptInResolvesExplicitBundlePath()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var bundle = CreateFakeCliBundle(workspace.WorkspaceRoot.FullName);
        var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
            $"""
              <AspireCliBundlePath>{bundle.LayoutRoot}</AspireCliBundlePath>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        BuildProject(appHostDirectory);
 
        var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
        Assert.Equal(bundle.DcpDir, resolvedPaths[0]);
        Assert.Equal(bundle.ManagedDir, resolvedPaths[1]);
        Assert.Equal(bundle.ManagedPath, resolvedPaths[2]);
    }
 
    [Fact]
    public async Task CliBundleOptInResolvesNewestVersionedBundlePath()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var layoutRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "layout");
        _ = CreateFakeCliBundleAtVersionedLayoutRoot(layoutRoot, "13.9.0-preview.1.25301.1_older-aaaaaaaaaaaaaaaa");
        var newestBundle = CreateFakeCliBundleAtVersionedLayoutRoot(layoutRoot, "13.10.0-preview.1.25301.1_newer-bbbbbbbbbbbbbbbb");
        var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
            $"""
              <AspireCliBundlePath>{layoutRoot}</AspireCliBundlePath>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        BuildProject(appHostDirectory);
 
        var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
        Assert.Equal(newestBundle.DcpDir, resolvedPaths[0]);
        Assert.Equal(newestBundle.ManagedDir, resolvedPaths[1]);
        Assert.Equal(newestBundle.ManagedPath, resolvedPaths[2]);
    }
 
    [Fact]
    public async Task CliBundleOptInIgnoresTemporaryVersionedBundleDirectories()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var layoutRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "layout");
        _ = CreateFakeCliBundleAtVersionedLayoutRoot(layoutRoot, "99.0.0-preview.1.tmp.aaaaaaaaaaaaaaaa");
        var newestBundle = CreateFakeCliBundleAtVersionedLayoutRoot(layoutRoot, "13.10.0-preview.1.25301.1_newer-bbbbbbbbbbbbbbbb");
        var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
            $"""
              <AspireCliBundlePath>{layoutRoot}</AspireCliBundlePath>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        BuildProject(appHostDirectory);
 
        var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
        Assert.Equal(newestBundle.DcpDir, resolvedPaths[0]);
        Assert.Equal(newestBundle.ManagedDir, resolvedPaths[1]);
        Assert.Equal(newestBundle.ManagedPath, resolvedPaths[2]);
    }
 
    [Fact]
    public void CliBundleDefaultEmitsWarning()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var appHostDirectory = CreateSdkBundleAppHostProject(
            workspace.WorkspaceRoot.FullName,
            repoRoot,
            additionalProperties: "",
            optIntoCliBundle: false);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        var output = BuildProject(appHostDirectory);
 
        Assert.Contains("warning ASPIRE010", output);
        Assert.Contains("Some Aspire features require the Aspire CLI bundle", output);
        Assert.Contains("Set AspireUseCliBundle=true", output);
        Assert.Contains("suppress ASPIRE010", output);
    }
 
    [Fact]
    public async Task CliBundleOptInResolvesAspireHomeBundleForSidecarlessCli()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "bin"));
        var fakeCliPath = CreateFakeAspireCli(fakeCliDirectory.FullName);
        var bundle = CreateFakeCliBundle(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-home"));
        var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
            $"""
              <AspireCliPath>{fakeCliPath}</AspireCliPath>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        BuildProject(appHostDirectory, new Dictionary<string, string> { ["ASPIRE_HOME"] = bundle.LayoutRoot });
 
        var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
        Assert.Equal(bundle.DcpDir, resolvedPaths[0]);
        Assert.Equal(bundle.ManagedDir, resolvedPaths[1]);
        Assert.Equal(bundle.ManagedPath, resolvedPaths[2]);
    }
 
    [Fact]
    public async Task CliBundleOptInPreparesMissingBundleBesidePathCli()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home");
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli"));
        _ = CreateFakeAspireCliThatSetsUpBundle(fakeCliDirectory.FullName);
        var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot, additionalProperties: "");
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        BuildProject(appHostDirectory, new Dictionary<string, string>
        {
            ["ASPIRE_HOME"] = aspireHome,
            ["PATH"] = GetPathWithoutAspire(fakeCliDirectory.FullName)
        });
 
        var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
        var bundleRoot = Path.Combine(workspace.WorkspaceRoot.FullName, BundleDiscovery.BundleDirectoryName);
        var expectedDcpDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "dcp"));
        var expectedManagedDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "managed"));
        Assert.Equal(expectedDcpDir, resolvedPaths[0]);
        Assert.Equal(expectedManagedDir, resolvedPaths[1]);
        Assert.Equal(Path.Combine(expectedManagedDir, OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"), resolvedPaths[2]);
        Assert.False(Directory.Exists(Path.Combine(aspireHome, BundleDiscovery.BundleDirectoryName)));
    }
 
    [Fact]
    public void CliBundleOptInSkipsSetupDuringDesignTimeBuild()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home");
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli"));
        var fakeCliPath = CreateFakeAspireCliThatSetsUpBundle(fakeCliDirectory.FullName);
        var appHostDirectory = CreateSdkBundleAppHostProject(
            workspace.WorkspaceRoot.FullName,
            repoRoot,
            $"""
              <AspireCliPath>{fakeCliPath}</AspireCliPath>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        var result = RunDotNet(
            appHostDirectory,
            "build --disable-build-servers -p:DesignTimeBuild=true",
            timeoutMilliseconds: 180_000,
            new Dictionary<string, string> { ["ASPIRE_HOME"] = aspireHome });
 
        Assert.True(result.ExitCode == 0, $"Design-time build failed: {Environment.NewLine}{result.Output}");
        Assert.False(Directory.Exists(Path.Combine(aspireHome, BundleDiscovery.BundleDirectoryName)));
        Assert.False(Directory.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, BundleDiscovery.BundleDirectoryName)));
    }
 
    [Fact]
    public void CliBundleSetupFailureReportsAspire009WhenWarningsAreErrors()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home");
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli"));
        var fakeCliPath = CreateFakeAspireCliThatFailsSetup(fakeCliDirectory.FullName);
        var appHostDirectory = CreateSdkBundleAppHostProject(
            workspace.WorkspaceRoot.FullName,
            repoRoot,
            $"""
              <AspireCliPath>{fakeCliPath}</AspireCliPath>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        var result = RunDotNet(
            appHostDirectory,
            "build --disable-build-servers -warnaserror",
            timeoutMilliseconds: 180_000,
            new Dictionary<string, string> { ["ASPIRE_HOME"] = aspireHome });
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("ASPIRE009", result.Output);
        Assert.Contains("failed with exit code 42", result.Output);
        Assert.Contains($"Run '\"{fakeCliPath}\" setup'", result.Output);
    }
 
    [Fact]
    public void CliBundleSetupTimeoutReportsAspire009()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home");
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli"));
        var fakeCliPath = CreateFakeAspireCliThatTimesOutSetup(fakeCliDirectory.FullName);
        var appHostDirectory = CreateSdkBundleAppHostProject(
            workspace.WorkspaceRoot.FullName,
            repoRoot,
            $"""
              <AspireCliPath>{fakeCliPath}</AspireCliPath>
              <_AspireCliBundleSetupTimeout>100</_AspireCliBundleSetupTimeout>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        var output = BuildProjectWithFailure(
            appHostDirectory,
            new Dictionary<string, string> { ["ASPIRE_HOME"] = aspireHome });
 
        Assert.Contains("ASPIRE009", output);
        Assert.Contains("Automatic Aspire CLI bundle setup did not produce a usable DCP and dashboard layout.", output);
        Assert.Contains("The command timed out", output, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task CliBundleOptInPreservesLiteralPercentCharactersInWindowsCommandShimPath()
    {
        Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows command-shim escaping is required.");
 
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home");
        var literalSegment = "%ASPIRE_LITERAL_SEGMENT%";
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, literalSegment));
        var fakeCliPath = CreateFakeAspireCliThatSetsUpBundle(fakeCliDirectory.FullName);
        var appHostDirectory = CreateSdkBundleAppHostProject(
            workspace.WorkspaceRoot.FullName,
            repoRoot,
            $"""
              <AspireCliPath>{fakeCliPath}</AspireCliPath>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        BuildProject(appHostDirectory, new Dictionary<string, string>
        {
            ["ASPIRE_HOME"] = aspireHome,
            ["ASPIRE_LITERAL_SEGMENT"] = "expanded-segment"
        });
 
        var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
        var bundleRoot = Path.Combine(workspace.WorkspaceRoot.FullName, BundleDiscovery.BundleDirectoryName);
        Assert.Equal(EnsureTrailingSeparator(Path.Combine(bundleRoot, BundleDiscovery.DcpDirectoryName)), resolvedPaths[0]);
        Assert.Equal(EnsureTrailingSeparator(Path.Combine(bundleRoot, BundleDiscovery.ManagedDirectoryName)), resolvedPaths[1]);
    }
 
    [Fact]
    public void CliBundleSetupFailureDiagnosticPreservesLiteralPercentCharacters()
    {
        Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows command-shim escaping is required.");
 
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var literalSegment = "%ASPIRE_LITERAL_SEGMENT%";
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, literalSegment));
        var fakeCliPath = CreateFakeAspireCliThatFailsSetup(fakeCliDirectory.FullName);
        var appHostDirectory = CreateSdkBundleAppHostProject(
            workspace.WorkspaceRoot.FullName,
            repoRoot,
            $"""
              <AspireCliPath>{fakeCliPath}</AspireCliPath>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        var output = BuildProjectWithFailure(appHostDirectory, new Dictionary<string, string>
        {
            ["ASPIRE_HOME"] = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home"),
            ["ASPIRE_LITERAL_SEGMENT"] = "expanded-segment"
        });
 
        Assert.Contains($"Run '\"{fakeCliPath}\" setup'", output);
        Assert.DoesNotContain("^^%%", output);
        Assert.DoesNotContain("cmd /D /V:OFF", output);
    }
 
    [Fact]
    public async Task CliBundleOptInResolvesAspireHomeBundleWithoutCliOnPath()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var bundle = CreateFakeCliBundle(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-home"));
        var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot, additionalProperties: "");
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        BuildProject(appHostDirectory, new Dictionary<string, string>
        {
            ["ASPIRE_HOME"] = bundle.LayoutRoot,
            ["PATH"] = GetDotNetOnlyPath()
        });
 
        var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
        Assert.Equal(bundle.DcpDir, resolvedPaths[0]);
        Assert.Equal(bundle.ManagedDir, resolvedPaths[1]);
        Assert.Equal(bundle.ManagedPath, resolvedPaths[2]);
    }
 
    [Fact]
    public async Task CliBundleOptInResolvesDotNetToolStoreBundleFromShimPath()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var toolsDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".dotnet", "tools"));
        var shimPath = CreateFakeAspireCli(toolsDirectory.FullName);
        var nativeCliDirectory = Directory.CreateDirectory(Path.Combine(
            toolsDirectory.FullName,
            ".store",
            "aspire.cli",
            "13.4.0",
            OperatingSystem.IsWindows() ? "aspire.cli.win-x64" : "aspire.cli.linux-x64",
            "13.4.0",
            "tools",
            "net10.0",
            OperatingSystem.IsWindows() ? "win-x64" : "linux-x64"));
        _ = CreateFakeAspireCli(nativeCliDirectory.FullName);
        File.WriteAllText(Path.Combine(nativeCliDirectory.FullName, ".aspire-install.json"), """{"source":"dotnet-tool"}""");
        var bundle = CreateFakeCliBundleAtLayoutRoot(nativeCliDirectory.FullName);
        var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
            $"""
              <AspireCliPath>{shimPath}</AspireCliPath>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        BuildProject(appHostDirectory);
 
        var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
        Assert.Equal(bundle.DcpDir, resolvedPaths[0]);
        Assert.Equal(bundle.ManagedDir, resolvedPaths[1]);
        Assert.Equal(bundle.ManagedPath, resolvedPaths[2]);
    }
 
    [Fact]
    public async Task CliBundleOptInPrefersExistingRepoPathsOverBundlePath()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var bundle = CreateFakeCliBundle(workspace.WorkspaceRoot.FullName);
        var repoDcpDir = EnsureTrailingSeparator(Path.Combine(workspace.WorkspaceRoot.FullName, "repo-dcp"));
        var repoDashboardDir = EnsureTrailingSeparator(Path.Combine(workspace.WorkspaceRoot.FullName, "repo-dashboard"));
        Directory.CreateDirectory(repoDcpDir);
        Directory.CreateDirectory(repoDashboardDir);
 
        var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
            $"""
              <DcpDir>{repoDcpDir}</DcpDir>
              <AspireDashboardDir>{repoDashboardDir}</AspireDashboardDir>
              <AspireCliBundlePath>{bundle.LayoutRoot}</AspireCliBundlePath>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        BuildProject(appHostDirectory);
 
        var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
        Assert.Equal(repoDcpDir, resolvedPaths[0]);
        Assert.Equal(repoDashboardDir, resolvedPaths[1]);
        Assert.Contains(Path.Combine(repoDashboardDir, "Aspire.Dashboard"), resolvedPaths[2]);
    }
 
    [Fact]
    public void CliBundleOptInFailsWhenExplicitBundlePathIsInvalid()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var missingBundlePath = Path.Combine(workspace.WorkspaceRoot.FullName, "missing-bundle");
        var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
            $"""
              <AspireCliInvocationMode>Dnx</AspireCliInvocationMode>
              <AspireCliBundlePath>{missingBundlePath}</AspireCliBundlePath>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        var output = BuildProjectWithFailure(appHostDirectory);
 
        Assert.Contains("warning", output);
        Assert.Contains("AspireCliBundlePath", output);
        Assert.Contains(missingBundlePath, output);
        Assert.Contains("ASPIRE009", output);
        Assert.Contains("the bundle could not be resolved", output);
        Assert.Contains("New features require the Aspire CLI to be installed.", output);
        Assert.Contains("https://get.aspire.dev", output);
        Assert.DoesNotContain("DCP path could not be resolved", output);
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("Dnx")]
    public void CliBundleDnxInvocationFailsWhenSetupCannotProduceLayout(string? invocationMode)
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var additionalProperties = invocationMode is null
            ? ""
            : $"""
              <AspireCliInvocationMode>{invocationMode}</AspireCliInvocationMode>
            """;
        var appHostDirectory = CreateSdkBundleAppHostProject(
            workspace.WorkspaceRoot.FullName,
            repoRoot,
            additionalProperties);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli"));
        var dnxPath = Path.Combine(fakeCliDirectory.FullName, OperatingSystem.IsWindows() ? "dnx.exe" : "dnx");
        if (OperatingSystem.IsWindows())
        {
            // Use a terminating PE executable. A copied cmd.exe starts an interactive shell
            // because the DNX arguments do not include /C and leaves the test waiting for timeout.
            File.Copy(Path.Combine(Environment.SystemDirectory, "where.exe"), dnxPath);
        }
        else
        {
            File.WriteAllText(dnxPath, "#!/bin/sh\nexit 42\n");
            File.SetUnixFileMode(dnxPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        var output = BuildProjectWithFailure(appHostDirectory, new Dictionary<string, string>
        {
            ["ASPIRE_HOME"] = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home"),
            ["PATH"] = GetPathWithoutAspire(fakeCliDirectory.FullName)
        });
 
        Assert.Contains("ASPIRE009", output);
        Assert.Contains("the bundle could not be resolved", output);
        Assert.Contains("failed with exit code", output);
        if (!OperatingSystem.IsWindows())
        {
            Assert.Contains("failed with exit code 42", output);
        }
    }
 
    [Fact]
    public void CliBundleOptInFailsWhenExplicitCliPathIsInvalid()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var missingCliPath = Path.Combine(workspace.WorkspaceRoot.FullName, "missing-aspire");
        var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
            $"""
              <AspireCliPath>{missingCliPath}</AspireCliPath>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        var output = BuildProjectWithFailure(appHostDirectory);
 
        Assert.Contains("warning", output);
        Assert.Contains("AspireCliPath", output);
        Assert.Contains(missingCliPath, output);
        Assert.Contains("ASPIRE009", output);
    }
 
    [Fact]
    public async Task CliBundleDefaultsToFalseForNonCSharpAppHostProject()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var appHostDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost");
        Directory.CreateDirectory(appHostDirectory);
        File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.fsproj"),
            """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net8.0</TargetFramework>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
 
              <Target Name="WriteAspireUseCliBundleProperty">
                <WriteLinesToFile File="$(BaseIntermediateOutputPath)aspire-use-cli-bundle.txt"
                                  Lines="Value=$(AspireUseCliBundle)"
                                  Overwrite="true" />
              </Target>
            </Project>
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        var result = RunDotNet(appHostDirectory, "msbuild AppHost.fsproj /t:WriteAspireUseCliBundleProperty /v:minimal", timeoutMilliseconds: 180_000);
 
        Assert.Equal(0, result.ExitCode);
        var property = await File.ReadAllTextAsync(Path.Combine(appHostDirectory, "obj", "aspire-use-cli-bundle.txt"));
        Assert.Equal("Value=false", property.Trim());
    }
 
    [Fact]
    public async Task CliBundleOptInKeepsSdkProjectReferenceMutation()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        CreateAppProject(workspace.WorkspaceRoot.FullName, "App");
        var bundle = CreateFakeCliBundle(workspace.WorkspaceRoot.FullName);
        var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
            $"""
              <AspireCliBundlePath>{bundle.LayoutRoot}</AspireCliBundlePath>
            """,
            """
                <ProjectReference Include="..\App\App.csproj" />
            """);
 
        CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        BuildProject(appHostDirectory);
 
        var metadataPath = Path.Combine(appHostDirectory, "obj", "Debug", "net8.0", "Aspire", "references", "App.ProjectMetadata.g.cs");
        var appMetadata = await File.ReadAllTextAsync(metadataPath);
 
        Assert.Contains("class App : global::Aspire.Hosting.IProjectMetadata", appMetadata);
    }
 
    private static string CreateSdkBundleAppHostProject(
        string basePath,
        string repoRoot,
        string additionalProperties,
        string additionalProjectReferences = "",
        bool optIntoCliBundle = true)
    {
        var appHostDirectory = Path.Combine(basePath, "AppHost");
        Directory.CreateDirectory(appHostDirectory);
 
        File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.csproj"),
            $"""
            <Project Sdk="Microsoft.NET.Sdk">
 
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net8.0</TargetFramework>
                <ImplicitUsings>enable</ImplicitUsings>
                <Nullable>enable</Nullable>
                <IsAspireHost>true</IsAspireHost>
                <AspireHostingSDKVersion>9.0.0</AspireHostingSDKVersion>
                <SkipAddAspireDefaultReferences>true</SkipAddAspireDefaultReferences>
                <_AspireUseTaskHostFactory>true</_AspireUseTaskHostFactory>
                {(optIntoCliBundle ? "<AspireUseCliBundle>true</AspireUseCliBundle>" : "")}
            {additionalProperties}
              </PropertyGroup>
 
              <ItemGroup>
                <ProjectReference Include="{repoRoot}\src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj" IsAspireProjectResource="false" />
            {additionalProjectReferences}
              </ItemGroup>
 
              <Target Name="WriteResolvedAspirePaths" AfterTargets="GetAssemblyAttributes">
                <ItemGroup>
                  <_AppHostIdentityMetadata Include="@(AssemblyAttribute)"
                                            Condition="'%(AssemblyAttribute._Parameter1)' == 'apphostprojectpath' or '%(AssemblyAttribute._Parameter1)' == 'apphostprojectname'" />
                </ItemGroup>
                <WriteLinesToFile File="$(BaseIntermediateOutputPath)resolved-aspire-paths.txt"
                                  Lines="$(DcpDir);$(AspireDashboardDir);$(AspireDashboardPath)"
                                  Overwrite="true" />
                <WriteLinesToFile File="$(BaseIntermediateOutputPath)apphost-identity-metadata.txt"
                                  Lines="@(_AppHostIdentityMetadata->'%(_Parameter1)=%(_Parameter2)')"
                                  Overwrite="true" />
              </Target>
 
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.cs"),
            """
            var builder = DistributedApplication.CreateBuilder();
            builder.Build().Run();
            """);
 
        return appHostDirectory;
    }
 
    private static void CreateAppHostPackageDirectoryBuildFiles(string basePath, string repoRoot)
    {
#if DEBUG
        var config = "Debug";
#else
        var config = "Release";
#endif
 
        File.WriteAllText(Path.Combine(basePath, "Directory.Build.props"),
        $"""
        <Project>
          <PropertyGroup>
            <SkipAspireWorkloadManifest>true</SkipAspireWorkloadManifest>
          </PropertyGroup>
 
          <Import Project="{repoRoot}\src\Aspire.Hosting.AppHost\build\Aspire.Hosting.AppHost.props" />
        </Project>
        """);
        File.WriteAllText(Path.Combine(basePath, "Directory.Build.targets"),
        $"""
        <Project>
          <PropertyGroup>
            <_AspireTasksAssembly>{repoRoot}\artifacts\bin\Aspire.Hosting.Tasks\{config}\net8.0\Aspire.Hosting.Tasks.dll</_AspireTasksAssembly>
          </PropertyGroup>
 
          <Import Project="{repoRoot}\src\Aspire.Hosting.AppHost\build\Aspire.Hosting.AppHost.in.targets" />
          <Import Project="{repoRoot}\src\Aspire.AppHost.Sdk\SDK\Sdk.in.targets" />
        </Project>
        """);
    }
 
    private static (string LayoutRoot, string DcpDir, string ManagedDir, string ManagedPath) CreateFakeCliBundle(string basePath)
    {
        return CreateFakeCliBundleAtLayoutRoot(Path.Combine(basePath, "layout"));
    }
 
    private static (string LayoutRoot, string DcpDir, string ManagedDir, string ManagedPath) CreateFakeCliBundleAtLayoutRoot(string layoutRoot)
    {
        return CreateFakeCliBundleRoot(layoutRoot, Path.Combine(layoutRoot, "bundle"));
    }
 
    private static (string LayoutRoot, string DcpDir, string ManagedDir, string ManagedPath) CreateFakeCliBundleAtVersionedLayoutRoot(string layoutRoot, string versionId)
    {
        return CreateFakeCliBundleRoot(layoutRoot, Path.Combine(layoutRoot, "versions", versionId));
    }
 
    private static (string LayoutRoot, string DcpDir, string ManagedDir, string ManagedPath) CreateFakeCliBundleRoot(string layoutRoot, string bundleRoot)
    {
        var dcpDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "dcp"));
        var managedDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "managed"));
        Directory.CreateDirectory(dcpDir);
        Directory.CreateDirectory(managedDir);
 
        File.WriteAllText(Path.Combine(dcpDir, OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"), "");
        var managedPath = Path.Combine(managedDir, OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed");
        File.WriteAllText(managedPath, "");
 
        return (layoutRoot, dcpDir, managedDir, managedPath);
    }
 
    private static string CreateFakeAspireCli(string directory)
    {
        var cliPath = Path.Combine(directory, OperatingSystem.IsWindows() ? "aspire.exe" : "aspire");
        File.WriteAllText(cliPath, "");
        return cliPath;
    }
 
    private static string CreateFakeAspireCliThatSetsUpBundle(string directory)
    {
        var cliPath = Path.Combine(directory, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");
        var dcpExecutable = OperatingSystem.IsWindows() ? "dcp.exe" : "dcp";
        var managedExecutable = OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed";
        var contents = OperatingSystem.IsWindows()
            ? $$"""
                @echo off
                if not "%~1"=="setup" exit /b 2
                mkdir "%~dp0..\bundle\dcp"
                mkdir "%~dp0..\bundle\managed"
                type nul > "%~dp0..\bundle\dcp\{{dcpExecutable}}"
                type nul > "%~dp0..\bundle\managed\{{managedExecutable}}"
                """
            : $$"""
                #!/bin/sh
                if [ "$1" != "setup" ]; then
                    exit 2
                fi
                install_path="$(dirname "$0")/.."
                mkdir -p "$install_path/bundle/dcp" "$install_path/bundle/managed"
                : > "$install_path/bundle/dcp/{{dcpExecutable}}"
                : > "$install_path/bundle/managed/{{managedExecutable}}"
                """;
 
        File.WriteAllText(cliPath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n"));
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(cliPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
 
        return cliPath;
    }
 
    private static string CreateFakeAspireCliThatTimesOutSetup(string directory)
    {
        var cliPath = Path.Combine(directory, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");
        var contents = OperatingSystem.IsWindows()
            ? """
                @echo off
                if not "%~1"=="setup" exit /b 2
                ping -n 6 127.0.0.1 > nul
                """
            : """
                #!/bin/sh
                if [ "$1" != "setup" ]; then
                    exit 2
                fi
                sleep 5
                """;
 
        File.WriteAllText(cliPath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n"));
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(cliPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
 
        return cliPath;
    }
 
    private static string CreateFakeAspireCliThatFailsSetup(string directory)
    {
        var cliPath = Path.Combine(directory, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");
        var contents = OperatingSystem.IsWindows()
            ? """
                @echo off
                if not "%~1"=="setup" exit /b 2
                exit /b 42
                """
            : """
                #!/bin/sh
                if [ "$1" != "setup" ]; then
                    exit 2
                fi
                exit 42
                """;
 
        File.WriteAllText(cliPath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n"));
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(cliPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
 
        return cliPath;
    }
 
    private static string EnsureTrailingSeparator(string path)
    {
        return path.EndsWith(Path.DirectorySeparatorChar) ? path : path + Path.DirectorySeparatorChar;
    }
 
    private static string GetDotNetOnlyPath()
    {
        var dotnetPath = DotnetFileAppProcess.ResolvedExecutablePath;
        var dotnetDirectory = Path.GetDirectoryName(dotnetPath);
 
        Assert.False(string.IsNullOrEmpty(dotnetDirectory), $"Could not determine the directory for dotnet path '{dotnetPath}'.");
 
        return dotnetDirectory;
    }
 
    private static string GetPathWithoutAspire(string firstDirectory)
    {
        var currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
        var pathDirectories = currentPath
            .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
            .Where(directory => !ContainsCommand(directory, "aspire"));
 
        return string.Join(Path.PathSeparator, pathDirectories.Prepend(firstDirectory));
    }
 
    private static bool ContainsCommand(string directory, string commandName)
    {
        var executableNames = OperatingSystem.IsWindows()
            ? new[] { $"{commandName}.exe", $"{commandName}.cmd", $"{commandName}.bat", commandName }
            : [commandName];
 
        return executableNames.Any(executableName => File.Exists(Path.Combine(directory.Trim().Trim('"'), executableName)));
    }
 
    /// <summary>
    /// Tests that when TreatProjectReferencesAsResources is set to false,
    /// ProjectReference items are not mutated with Aspire-specific metadata.
    /// </summary>
    [Fact]
    public void TreatProjectReferencesAsResourcesFalse_DisablesMutation()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        CreateLibraryProject(workspace.WorkspaceRoot.FullName, "Library");
 
        var appHostDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost");
        Directory.CreateDirectory(appHostDirectory);
 
        File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.csproj"),
            $"""
            <Project Sdk="Microsoft.NET.Sdk">
 
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net8.0</TargetFramework>
                <ImplicitUsings>enable</ImplicitUsings>
                <Nullable>enable</Nullable>
                <IsAspireHost>true</IsAspireHost>
                <TreatProjectReferencesAsResources>false</TreatProjectReferencesAsResources>
 
                <!--
                  Test applications have their own way of referencing Aspire.Hosting.AppHost, as well as DCP and Dashboard, so we disable
                  the Aspire.AppHost.SDK targets that will automatically add these references to projects.
                -->
                <SkipAddAspireDefaultReferences Condition="'$(TestsRunningOutsideOfRepo)' != 'true'">true</SkipAddAspireDefaultReferences>
                <AspireHostingSDKVersion>9.0.0</AspireHostingSDKVersion>
                <_AspireUseTaskHostFactory>true</_AspireUseTaskHostFactory>
              </PropertyGroup>
 
              <ItemGroup>
                <ProjectReference Include="{repoRoot}\src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj" />
                <ProjectReference Include="..\Library\Library.csproj" />
              </ItemGroup>
 
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.cs"),
            """
            var builder = DistributedApplication.CreateBuilder();
            builder.Build().Run();
            """);
 
        CreateDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        var output = BuildProject(appHostDirectory);
 
        // When TreatProjectReferencesAsResources is false, the Library project should be treated as a normal reference
        // and no ASPIRE004 warning should be emitted since the references are not being mutated
        Assert.DoesNotContain("warning ASPIRE004", output);
    }
 
    /// <summary>
    /// Tests that when TreatProjectReferencesAsResources is explicitly set to true,
    /// ProjectReference items are mutated with Aspire-specific metadata (same as default).
    /// </summary>
    [Fact]
    public void TreatProjectReferencesAsResourcesTrue_EnablesMutation()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        CreateLibraryProject(workspace.WorkspaceRoot.FullName, "Library");
 
        var appHostDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost");
        Directory.CreateDirectory(appHostDirectory);
 
        File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.csproj"),
            $"""
            <Project Sdk="Microsoft.NET.Sdk">
 
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net8.0</TargetFramework>
                <ImplicitUsings>enable</ImplicitUsings>
                <Nullable>enable</Nullable>
                <IsAspireHost>true</IsAspireHost>
                <TreatProjectReferencesAsResources>true</TreatProjectReferencesAsResources>
 
                <!--
                  Test applications have their own way of referencing Aspire.Hosting.AppHost, as well as DCP and Dashboard, so we disable
                  the Aspire.AppHost.SDK targets that will automatically add these references to projects.
                -->
                <SkipAddAspireDefaultReferences Condition="'$(TestsRunningOutsideOfRepo)' != 'true'">true</SkipAddAspireDefaultReferences>
                <AspireHostingSDKVersion>9.0.0</AspireHostingSDKVersion>
                <_AspireUseTaskHostFactory>true</_AspireUseTaskHostFactory>
              </PropertyGroup>
 
              <ItemGroup>
                <ProjectReference Include="{repoRoot}\src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj" IsAspireProjectResource="false" />
                <ProjectReference Include="..\Library\Library.csproj" />
              </ItemGroup>
 
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.cs"),
            """
            var builder = DistributedApplication.CreateBuilder();
            builder.Build().Run();
            """);
 
        CreateDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        var output = BuildProject(appHostDirectory);
 
        // When TreatProjectReferencesAsResources is explicitly set to true, the mutation should happen
        // and ASPIRE004 warning should be emitted for the Library project reference
        Assert.Contains("warning ASPIRE004", output);
    }
 
    [Fact]
    public void AspireExportAnalyzersAreDisabledByDefault()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var projectDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "MyHostingExtension");
        Directory.CreateDirectory(projectDirectory);
 
        File.WriteAllText(Path.Combine(projectDirectory, "MyHostingExtension.csproj"),
            $"""
            <Project Sdk="Microsoft.NET.Sdk">
 
              <PropertyGroup>
                <TargetFramework>net8.0</TargetFramework>
                <ImplicitUsings>enable</ImplicitUsings>
                <Nullable>enable</Nullable>
              </PropertyGroup>
 
              <ItemGroup>
                <ProjectReference Include="{repoRoot}\src\Aspire.Hosting\Aspire.Hosting.csproj" />
              </ItemGroup>
 
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(projectDirectory, "Extensions.cs"),
            """
            using Aspire.Hosting;
            using Aspire.Hosting.ApplicationModel;
 
            namespace MyHostingExtension;
 
            public static class CustomResourceExtensions
            {
                public static IResourceBuilder<ContainerResource> AddCustomContainer(this IDistributedApplicationBuilder builder)
                {
                    return builder.AddContainer("custom", "custom-image");
                }
            }
            """);
 
        CreateExportAnalyzerDirectoryBuildFiles(projectDirectory, repoRoot);
 
        var output = BuildProject(projectDirectory);
 
        Assert.DoesNotContain("warning ASPIREEXPORT008", output);
    }
 
    [Fact]
    public void AspireExportAnalyzersCanBeEnabledWithMsBuildProperty()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var projectDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "MyHostingExtension");
        Directory.CreateDirectory(projectDirectory);
 
        File.WriteAllText(Path.Combine(projectDirectory, "MyHostingExtension.csproj"),
            $"""
            <Project Sdk="Microsoft.NET.Sdk">
 
              <PropertyGroup>
                <TargetFramework>net8.0</TargetFramework>
                <ImplicitUsings>enable</ImplicitUsings>
                <Nullable>enable</Nullable>
              </PropertyGroup>
 
              <ItemGroup>
                <ProjectReference Include="{repoRoot}\src\Aspire.Hosting\Aspire.Hosting.csproj" />
              </ItemGroup>
 
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(projectDirectory, "Extensions.cs"),
            """
            using Aspire.Hosting;
            using Aspire.Hosting.ApplicationModel;
 
            namespace MyHostingExtension;
 
            public static class CustomResourceExtensions
            {
                public static IResourceBuilder<ContainerResource> AddCustomContainer(this IDistributedApplicationBuilder builder)
                {
                    return builder.AddContainer("custom", "custom-image");
                }
            }
            """);
 
        CreateExportAnalyzerDirectoryBuildFiles(projectDirectory, repoRoot, enableAspireIntegrationAnalyzers: true);
 
        var output = BuildProject(projectDirectory);
 
        Assert.Contains("warning ASPIREEXPORT008", output);
    }
 
    [Fact]
    public void AspireIntegrationAnalyzerPackageContainsExpectedAssets()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var packageOutputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "packages");
        Directory.CreateDirectory(packageOutputPath);
 
        var packagePath = PackProject(
            Path.Combine(repoRoot, "src", "Aspire.Hosting.Integration.Analyzers", "Aspire.Hosting.Integration.Analyzers.csproj"),
            packageOutputPath,
            "Aspire.Hosting.Integration.Analyzers");
 
        using var archive = ZipFile.OpenRead(packagePath);
 
        Assert.Contains(archive.Entries, entry => entry.FullName == "analyzers/dotnet/cs/Aspire.Hosting.Integration.Analyzers.dll");
        Assert.Contains(archive.Entries, entry => entry.FullName == "README.md");
        Assert.DoesNotContain(archive.Entries, entry => entry.FullName.StartsWith("lib/", StringComparison.Ordinal));
 
        var nuspecEntry = archive.Entries.Single(entry => entry.FullName.EndsWith(".nuspec", StringComparison.Ordinal));
        using var nuspecReader = new StreamReader(nuspecEntry.Open());
        var nuspec = nuspecReader.ReadToEnd();
 
        Assert.DoesNotContain("<dependency", nuspec, StringComparison.Ordinal);
    }
 
    [Fact]
    public void AspireHostingBlazorPackageContainsScriptsForBuildAndDirectLoading()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var packageOutputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "packages");
        Directory.CreateDirectory(packageOutputPath);
 
        var packagePath = PackProject(
            Path.Combine(repoRoot, "src", "Aspire.Hosting.Blazor", "Aspire.Hosting.Blazor.csproj"),
            packageOutputPath,
            "Aspire.Hosting.Blazor");
 
        using var archive = ZipFile.OpenRead(packagePath);
 
        var assemblyEntry = Assert.Single(
            archive.Entries,
            entry => entry.FullName.StartsWith("lib/", StringComparison.Ordinal)
                && entry.Name == "Aspire.Hosting.Blazor.dll");
        var targetFramework = assemblyEntry.FullName.Split('/')[1];
 
        Assert.Contains(
            archive.Entries,
            entry => entry.FullName == $"buildTransitive/{targetFramework}/Aspire.Hosting.Blazor.targets");
 
        foreach (var scriptName in new[] { "Gateway.cs", "PrefixEndpoints.cs" })
        {
            var buildTransitiveEntry = Assert.Single(
                archive.Entries,
                entry => entry.FullName == $"buildTransitive/{targetFramework}/Scripts/{scriptName}");
            var directLoadEntry = Assert.Single(
                archive.Entries,
                entry => entry.FullName == $"lib/{targetFramework}/Scripts/{scriptName}");
 
            using var buildTransitiveStream = buildTransitiveEntry.Open();
            using var directLoadStream = directLoadEntry.Open();
            using var buildTransitiveContent = new MemoryStream();
            using var directLoadContent = new MemoryStream();
            buildTransitiveStream.CopyTo(buildTransitiveContent);
            directLoadStream.CopyTo(directLoadContent);
 
            Assert.Equal(buildTransitiveContent.ToArray(), directLoadContent.ToArray());
        }
    }
 
    [Fact]
    public void AspireIntegrationAnalyzerPackageCanBeConsumedFromLocalSource()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var packageOutputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "packages");
        Directory.CreateDirectory(packageOutputPath);
 
        var analyzerPackagePath = PackProject(
            Path.Combine(repoRoot, "src", "Aspire.Hosting.Integration.Analyzers", "Aspire.Hosting.Integration.Analyzers.csproj"),
            packageOutputPath,
            "Aspire.Hosting.Integration.Analyzers");
 
        var analyzerVersion = ReadPackageVersion(analyzerPackagePath);
 
        var projectDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "MyHostingExtension");
        Directory.CreateDirectory(projectDirectory);
        var nuGetConfigPath = Path.Combine(projectDirectory, "NuGet.config");
 
        File.WriteAllText(nuGetConfigPath,
            $"""
            <?xml version="1.0" encoding="utf-8"?>
            <configuration>
              <packageSources>
                <clear />
                <add key="local" value="{packageOutputPath}" />
                <add key="dotnet-public" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json" />
                <add key="dotnet-eng" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json" />
                <add key="dotnet9" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json" />
                <add key="dotnet10" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet10/nuget/v3/index.json" />
                <add key="dotnet-libraries" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-libraries/nuget/v3/index.json" />
                <add key="dotnet9-transport" value="https://dnceng.pkgs.visualstudio.com/public/_packaging/dotnet9-transport/nuget/v3/index.json" />
              </packageSources>
              <packageSourceMapping>
                <packageSource key="local">
                  <package pattern="Aspire.Hosting.Integration.Analyzers" />
                </packageSource>
                <packageSource key="dotnet9-transport">
                  <package pattern="*WorkloadBuildTasks*" />
                </packageSource>
                <packageSource key="dotnet-public">
                  <package pattern="*" />
                  <package pattern="Microsoft.FluentUI.AspNetCore.Components" />
                  <package pattern="Microsoft.FluentUI.AspNetCore.Components.Icons" />
                </packageSource>
                <packageSource key="dotnet9">
                  <package pattern="*" />
                </packageSource>
                <packageSource key="dotnet10">
                  <package pattern="*" />
                </packageSource>
                <packageSource key="dotnet-libraries">
                  <package pattern="Microsoft.DeveloperControlPlane*" />
                </packageSource>
                <packageSource key="dotnet-eng">
                  <package pattern="*" />
                </packageSource>
              </packageSourceMapping>
            </configuration>
            """);
 
        File.WriteAllText(Path.Combine(projectDirectory, "MyHostingExtension.csproj"),
            $"""
            <Project Sdk="Microsoft.NET.Sdk">
 
              <PropertyGroup>
                <TargetFramework>net8.0</TargetFramework>
                <ImplicitUsings>enable</ImplicitUsings>
                <Nullable>enable</Nullable>
                <RestoreConfigFile>{nuGetConfigPath}</RestoreConfigFile>
                <!-- The synthetic fixture intentionally exposes no [AspireExport] surface (it only exercises
                     ASPIREEXPORT008), so opt out of polyglot compatibility to avoid the assembly-level
                     ASPIREEXPORT017 error that otherwise fires for an integration with no export coverage. -->
                <IsAspirePolyglotCompatible>false</IsAspirePolyglotCompatible>
              </PropertyGroup>
 
              <ItemGroup>
                <PackageReference Include="Aspire.Hosting.Integration.Analyzers" Version="{analyzerVersion}" PrivateAssets="all" />
                <ProjectReference Include="{repoRoot}\src\Aspire.Hosting\Aspire.Hosting.csproj" />
              </ItemGroup>
 
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(projectDirectory, "Extensions.cs"),
            """
            using Aspire.Hosting;
            using Aspire.Hosting.ApplicationModel;
 
            namespace MyHostingExtension;
 
            public static class CustomResourceExtensions
            {
                public static IResourceBuilder<ContainerResource> AddCustomContainer(this IDistributedApplicationBuilder builder)
                {
                    return builder.AddContainer("custom", "custom-image");
                }
            }
            """);
 
        var output = BuildProject(projectDirectory);
 
        Assert.Contains("warning ASPIREEXPORT008", output);
        Assert.DoesNotContain("CS8032", output);
    }
 
    private static void CreateExportAnalyzerDirectoryBuildFiles(
        string basePath,
        string repoRoot,
        bool enableAspireIntegrationAnalyzers = false)
    {
        File.WriteAllText(Path.Combine(basePath, "Directory.Build.props"),
        $"""
        <Project>
          <PropertyGroup>
            <EnableAspireIntegrationAnalyzers>{enableAspireIntegrationAnalyzers.ToString().ToLowerInvariant()}</EnableAspireIntegrationAnalyzers>
            <!-- The synthetic fixture intentionally exposes no [AspireExport] surface (it only exercises
                 ASPIREEXPORT008), so opt out of polyglot compatibility to avoid the assembly-level
                 ASPIREEXPORT017 error that otherwise fires for an integration with no export coverage. -->
            <IsAspirePolyglotCompatible>false</IsAspirePolyglotCompatible>
          </PropertyGroup>
        </Project>
        """);
        File.WriteAllText(Path.Combine(basePath, "Directory.Build.targets"),
        $"""
        <Project>
          <ItemGroup Condition="'$(EnableAspireIntegrationAnalyzers)' == 'true'">
            <ProjectReference Include="{repoRoot}\src\Aspire.Hosting.Integration.Analyzers\Aspire.Hosting.Integration.Analyzers.csproj"
                              PrivateAssets="all"
                              ReferenceOutputAssembly="false"
                              OutputItemType="Analyzer"
                              SetTargetFramework="TargetFramework=netstandard2.0" />
          </ItemGroup>
 
          <Import Project="{repoRoot}\src\Aspire.Hosting\buildTransitive\Aspire.Hosting.targets" />
        </Project>
        """);
    }
 
    /// <summary>
    /// Tests that when GenerateAssemblyInfo is set to false, a build error is emitted.
    /// </summary>
    [Fact]
    public void GenerateAssemblyInfoFalse_EmitsError()
    {
        var repoRoot = MSBuildUtils.GetRepoRoot();
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var appHostDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost");
        Directory.CreateDirectory(appHostDirectory);
 
        File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.csproj"),
            $"""
            <Project Sdk="Microsoft.NET.Sdk">
 
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net8.0</TargetFramework>
                <ImplicitUsings>enable</ImplicitUsings>
                <Nullable>enable</Nullable>
                <IsAspireHost>true</IsAspireHost>
                <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
 
                <!--
                  Test applications have their own way of referencing Aspire.Hosting.AppHost, as well as DCP and Dashboard, so we disable
                  the Aspire.AppHost.SDK targets that will automatically add these references to projects.
                -->
                <SkipAddAspireDefaultReferences Condition="'$(TestsRunningOutsideOfRepo)' != 'true'">true</SkipAddAspireDefaultReferences>
                <AspireHostingSDKVersion>9.0.0</AspireHostingSDKVersion>
                <_AspireUseTaskHostFactory>true</_AspireUseTaskHostFactory>
              </PropertyGroup>
 
              <ItemGroup>
                <ProjectReference Include="{repoRoot}\src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj" IsAspireProjectResource="false" />
              </ItemGroup>
 
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.cs"),
            """
            var builder = DistributedApplication.CreateBuilder();
            builder.Build().Run();
            """);
 
        CreateDirectoryBuildFiles(appHostDirectory, repoRoot);
 
        // Build should fail
        var output = new StringBuilder();
        var outputDone = new ManualResetEvent(false);
        using var process = new Process();
        process.StartInfo = new ProcessStartInfo("dotnet", "build --disable-build-servers")
        {
            RedirectStandardOutput = true,
            UseShellExecute = false,
            CreateNoWindow = true,
            WorkingDirectory = appHostDirectory
        };
        process.OutputDataReceived += (sender, e) =>
        {
            if (e.Data == null)
            {
                outputDone.Set();
            }
            else
            {
                output.AppendLine(e.Data);
            }
        };
        process.Start();
        process.BeginOutputReadLine();
 
        Assert.True(process.WaitForExit(milliseconds: 180_000), "dotnet build command timed out after 3 minutes.");
        Assert.True(outputDone.WaitOne(millisecondsTimeout: 60_000), "Timed out waiting for output to complete.");
 
        var buildOutput = output.ToString();
 
        // Build should fail with ASPIRE008 error
        Assert.NotEqual(0, process.ExitCode);
        Assert.Contains("error ASPIRE008", buildOutput);
        Assert.Contains("GenerateAssemblyInfo", buildOutput);
    }
}