File: BundleSmokeTests.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Hex1b.Input;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end tests for running .NET csproj AppHost projects using the Aspire bundle.
/// Validates that the bundle correctly provides DCP and Dashboard paths to the hosting
/// infrastructure when running SDK-based app hosts (not just polyglot/guest app hosts).
/// </summary>
public sealed class BundleSmokeTests(ITestOutputHelper output)
{
    [CaptureWorkspaceOnFailure]
    [Fact]
    public async Task CreateAndRunAspireStarterProjectWithBundle()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
 
        var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);
 
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
 
        await auto.AspireNewAsync("BundleStarterApp", counter);
 
        await auto.AspireStartAsync(counter);
        await auto.AspireStopAsync(counter);
    }
 
    [CaptureWorkspaceOnFailure]
    [Fact]
    public async Task DotNetRunProjectAppHostUsesAspireCliBundle()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var projectName = "DotNetRunBundleApp";
 
        var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);
 
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
 
        await auto.AspireNewAsync(projectName, counter, useRedisCache: false);
 
        var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
        var appHostDirectory = Path.Combine(projectRoot, $"{projectName}.AppHost");
        var appHostProjectPath = Path.Combine(appHostDirectory, $"{projectName}.AppHost.csproj");
        var appHostSourcePath = Path.Combine(appHostDirectory, "AppHost.cs");
        var argsOutputPath = Path.Combine(projectRoot, "apphost-args.txt");
        var containerArgsOutputPath = CliE2ETestHelpers.ToContainerPath(argsOutputPath, workspace);
 
        Assert.True(File.Exists(appHostProjectPath), $"Expected AppHost project file to exist at: {appHostProjectPath}");
        Assert.True(File.Exists(appHostSourcePath), $"Expected AppHost source file to exist at: {appHostSourcePath}");
 
        var appHostProject = File.ReadAllText(appHostProjectPath);
        Assert.Contains("<Nullable>enable</Nullable>", appHostProject);
        Assert.Contains("<AspireUseCliBundle>true</AspireUseCliBundle>", appHostProject);
 
        File.WriteAllText(
            appHostSourcePath,
            $$"""
            File.WriteAllText({{JsonSerializer.Serialize(containerArgsOutputPath)}}, string.Join("|", args));
 
            var builder = DistributedApplication.CreateBuilder(args);
 
            builder.Build().Run();
            """);
 
        var appHostProjectRelativePath = Path.GetRelativePath(workspace.WorkspaceRoot.FullName, appHostProjectPath);
        var quotedAppHostProjectPath = AspireCliShellCommandHelpers.QuoteBashArg(appHostProjectRelativePath);
 
        await auto.TypeAsync($"dotnet run --no-launch-profile --project {quotedAppHostProjectPath} -- --from-dotnet-run");
        await auto.EnterAsync();
 
        await auto.WaitUntilAsync(
            s => s.ContainsText("Press CTRL+C to stop the AppHost and exit."),
            timeout: CliE2EAutomatorHelpers.AspireRunReadyTimeout,
            description: "Press CTRL+C message from aspire run");
 
        await auto.Ctrl().KeyAsync(Hex1bKey.C);
        await auto.WaitForSuccessPromptAsync(counter);
 
        Assert.True(File.Exists(argsOutputPath), $"Expected AppHost to write forwarded args to: {argsOutputPath}");
        Assert.Equal("--from-dotnet-run", File.ReadAllText(argsOutputPath));
    }
 
    [CaptureWorkspaceOnFailure]
    [Fact]
    public async Task DotNetRunProjectAppHostRestoresAndUsesAspireCliThroughDnx()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        Assert.SkipUnless(
            strategy.Mode is CliInstallMode.LocalHive or CliInstallMode.LocalArchive,
            "Real DNX delegation requires a locally built Aspire CLI package hive.");
 
        const string ProjectName = "DotNetRunDnxBundleApp";
        var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);
 
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
        await auto.AspireNewAsync(ProjectName, counter, useRedisCache: false);
 
        var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName);
        var appHostDirectory = Path.Combine(projectRoot, $"{ProjectName}.AppHost");
        var appHostProjectPath = Path.Combine(appHostDirectory, $"{ProjectName}.AppHost.csproj");
        var appHostSourcePath = Path.Combine(appHostDirectory, "AppHost.cs");
        var argsOutputPath = Path.Combine(projectRoot, "apphost-dnx-args.txt");
        var containerArgsOutputPath = CliE2ETestHelpers.ToContainerPath(argsOutputPath, workspace);
 
        Assert.True(File.Exists(appHostProjectPath), $"Expected AppHost project file to exist at: {appHostProjectPath}");
        Assert.True(File.Exists(appHostSourcePath), $"Expected AppHost source file to exist at: {appHostSourcePath}");
 
        var appHostProject = File.ReadAllText(appHostProjectPath);
        appHostProject = appHostProject.Replace(
            "<Nullable>enable</Nullable>",
            """
            <Nullable>enable</Nullable>
                <AspireUseCliBundle>true</AspireUseCliBundle>
            """,
            StringComparison.Ordinal);
        File.WriteAllText(appHostProjectPath, appHostProject);
 
        var appHostSource = File.ReadAllText(appHostSourcePath);
        File.WriteAllText(
            appHostSourcePath,
            appHostSource.Replace(
                "var builder = DistributedApplication.CreateBuilder(args);",
                $$"""
                File.WriteAllText({{JsonSerializer.Serialize(containerArgsOutputPath)}}, string.Join("|", args));
 
                var builder = DistributedApplication.CreateBuilder(args);
                """,
                StringComparison.Ordinal));
 
        await auto.RunCommandAsync($"cd {AspireCliShellCommandHelpers.QuoteBashArg(ProjectName)}", counter);
        await auto.RunCommandAsync("test ! -e .config/dotnet-tools.json", counter);
 
        // The PR/local-hive installer places the exact locally built packages under this hive.
        // Ensuring it is registered in the app's NuGet.config makes the target's pinned DNX package
        // reference resolve the same Aspire.Cli build as the AppHost SDK without relying on a tool manifest.
        await auto.RunCommandAsync(
            "DNX_HIVE=$(find \"$HOME/.aspire/hives\" -mindepth 2 -maxdepth 2 -type d -name packages -print -quit); " +
            "test -n \"$DNX_HIVE\"; " +
            "NUGET_CONFIG=$(find . -maxdepth 1 -type f -iname nuget.config -print -quit); " +
            "if [ -z \"$NUGET_CONFIG\" ]; then NUGET_CONFIG=NuGet.config; dotnet new nugetconfig; fi; " +
            "dotnet nuget list source --configfile \"$NUGET_CONFIG\" | grep -F \"$DNX_HIVE\" >/dev/null || " +
            "dotnet nuget add source \"$DNX_HIVE\" --name aspire-dnx-local --configfile \"$NUGET_CONFIG\"",
            counter);
 
        // Remove both supported Aspire installation locations while leaving the SDK-adjacent DNX
        // executable available. This forces the automatic fallback rather than PATH CLI delegation.
        await auto.RunCommandAsync(
            "export PATH=\"${PATH#\"$HOME/.aspire/bin:\"}\"; " +
            "export PATH=\"${PATH#\"$HOME/.aspire:\"}\"; " +
            "hash -r; ! command -v aspire; command -v dnx",
            counter);
 
        var appHostProjectRelativePath = Path.GetRelativePath(projectRoot, appHostProjectPath);
        await auto.TypeAsync(
            $"dotnet run --no-launch-profile --project {AspireCliShellCommandHelpers.QuoteBashArg(appHostProjectRelativePath)} -- --from-real-dnx");
        await auto.EnterAsync();
 
        await auto.WaitUntilAsync(
            s => s.ContainsText("Press CTRL+C to stop the AppHost and exit."),
            timeout: CliE2EAutomatorHelpers.AspireRunReadyTimeout,
            description: "Press CTRL+C message from Aspire CLI restored through DNX");
 
        await auto.Ctrl().KeyAsync(Hex1bKey.C);
        await auto.WaitForSuccessPromptAsync(counter);
 
        Assert.True(File.Exists(argsOutputPath), $"Expected AppHost to write forwarded args to: {argsOutputPath}");
        Assert.Equal("--from-real-dnx", File.ReadAllText(argsOutputPath));
    }
 
    [CaptureWorkspaceOnFailure]
    [Fact]
    public async Task DotNetRunFileBasedAppHostUsesAspireCliBundle()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var projectName = "DotNetRunFileBundleApp";
 
        var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);
 
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
 
        await auto.AspireNewCSharpEmptyAppHostAsync(projectName, counter);
 
        var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
        var appHostSourcePath = Path.Combine(projectRoot, "apphost.cs");
        var argsOutputPath = Path.Combine(projectRoot, "apphost-file-args.txt");
        var containerArgsOutputPath = CliE2ETestHelpers.ToContainerPath(argsOutputPath, workspace);
 
        Assert.True(File.Exists(appHostSourcePath), $"Expected AppHost source file to exist at: {appHostSourcePath}");
 
        var appHostSource = File.ReadAllText(appHostSourcePath);
        var sdkDirective = File.ReadLines(appHostSourcePath).First();
        Assert.StartsWith("#:sdk Aspire.AppHost.Sdk@", sdkDirective);
        Assert.Contains("var builder = DistributedApplication.CreateBuilder(args);", appHostSource);
        Assert.Contains("#:property AspireUseCliBundle=true", appHostSource);
 
        File.WriteAllText(
            appHostSourcePath,
            appHostSource.Replace(
                "var builder = DistributedApplication.CreateBuilder(args);",
                $$"""
                File.WriteAllText({{JsonSerializer.Serialize(containerArgsOutputPath)}}, string.Join("|", args));
 
                var builder = DistributedApplication.CreateBuilder(args);
                """));
 
        await auto.RunCommandAsync($"cd {AspireCliShellCommandHelpers.QuoteBashArg(projectName)}", counter);
 
        await auto.TypeAsync("dotnet run apphost.cs -- --from-dotnet-run-file");
        await auto.EnterAsync();
 
        await auto.WaitUntilAsync(
            s => s.ContainsText("Press CTRL+C to stop the AppHost and exit."),
            timeout: CliE2EAutomatorHelpers.AspireRunReadyTimeout,
            description: "Press CTRL+C message from aspire run");
 
        await auto.Ctrl().KeyAsync(Hex1bKey.C);
        await auto.WaitForSuccessPromptAsync(counter);
 
        Assert.True(File.Exists(argsOutputPath), $"Expected AppHost to write forwarded args to: {argsOutputPath}");
        Assert.Equal("--from-dotnet-run-file", File.ReadAllText(argsOutputPath));
    }
}