// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Runtime.CompilerServices;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Commands;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Tests.TestServices;
using Microsoft.Extensions.DependencyInjection;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Microsoft.AspNetCore.InternalTesting;
namespace Aspire.Cli.Tests.Commands;
public class DeployCommandTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task DeployCommandWithHelpArgumentReturnsZero()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("deploy --help");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
}
[Fact]
public async Task DeployCommandInExtensionForwardsResolvedAspireHome()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var expectedAspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, ".home", ".aspire");
DebugSessionOptions? capturedOptions = null;
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel();
options.InteractionServiceFactory = sp =>
{
var interactionService = new TestExtensionInteractionService(sp);
interactionService.StartDebugSessionCallback = (_, _, _, debugSessionOptions) =>
{
capturedOptions = debugSessionOptions;
};
return interactionService;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("deploy");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.NotNull(capturedOptions);
Assert.Equal(
new Dictionary<string, string>
{
[KnownConfigNames.AspireHome] = expectedAspireHome
},
capturedOptions.EnvironmentVariables);
}
[Theory]
[InlineData("deploy", "deploy", null, false, "default-discovery")]
[InlineData("deploy", "deploy", null, true, "explicit-cli")]
[InlineData("publish", "publish", null, false, "default-discovery")]
[InlineData("publish", "publish", null, true, "explicit-cli")]
[InlineData("do", "do deploy", "deploy", false, "default-discovery")]
[InlineData("do", "do deploy", "deploy", true, "explicit-cli")]
public async Task PipelineCommands_WhenDelegatingToExtension_CarryAppHostSelectionOrigin(
string commandName,
string commandText,
string? expectedCommandArg,
bool explicitAppHost,
string expectedOrigin)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
var expectedAspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, ".home", ".aspire");
string? workingDirectory = null;
string? projectFile = null;
bool? debug = null;
DebugSessionOptions? capturedOptions = null;
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (passedProjectFile, _, _, _) =>
{
var selectedProjectFile = passedProjectFile ?? appHostFile;
return Task.FromResult(new AppHostProjectSearchResult(selectedProjectFile, [selectedProjectFile]));
}
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel();
options.InteractionServiceFactory = sp =>
{
var interactionService = new TestExtensionInteractionService(sp);
interactionService.StartDebugSessionCallback = (capturedWorkingDirectory, capturedProjectFile, capturedDebug, debugSessionOptions) =>
{
workingDirectory = capturedWorkingDirectory;
projectFile = capturedProjectFile;
debug = capturedDebug;
capturedOptions = debugSessionOptions;
};
return interactionService;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var invocation = explicitAppHost ? $"{commandText} --apphost \"{appHostFile.FullName}\"" : commandText;
var result = command.Parse(invocation);
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(workspace.WorkspaceRoot.FullName, workingDirectory);
Assert.Equal(appHostFile.FullName, projectFile);
Assert.True(debug);
Assert.NotNull(capturedOptions);
Assert.Equal(commandName, capturedOptions.Command);
if (expectedCommandArg is null)
{
Assert.Null(capturedOptions.Args);
}
else
{
Assert.NotNull(capturedOptions.Args);
Assert.Equal([expectedCommandArg], capturedOptions.Args);
}
Assert.Equal(
new Dictionary<string, string>
{
[KnownConfigNames.AspireHome] = expectedAspireHome
},
capturedOptions.EnvironmentVariables);
Assert.Equal(expectedOrigin, capturedOptions.AppHostSelectionOrigin);
}
[Fact]
public async Task DeployCommandFailsWithInvalidProjectFile()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
// Arrange
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner
{
GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) =>
{
return (1, false, null); // Simulate failure to retrieve app host information
}
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
// Act
var result = command.Parse("deploy --apphost invalid.csproj");
var exitCode = await result.InvokeAsync().DefaultTimeout();
// Assert
Assert.Equal(CliExitCodes.FailedToFindProject, exitCode); // Ensure the command fails
}
[Fact]
public async Task DeployCommandFailsWhenAppHostIsNotCompatible()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
// Arrange
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.ProjectLocatorFactory = (sp) => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner
{
GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) =>
{
return (0, false, "9.0.0"); // Simulate an incompatible app host
}
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
// Act
var result = command.Parse("deploy --apphost valid.csproj");
var exitCode = await result.InvokeAsync().DefaultTimeout();
// Assert
Assert.Equal(CliExitCodes.AppHostIncompatible, exitCode); // Ensure the command fails
}
[Fact]
public async Task DeployCommandFailsWhenAppHostBuildFails()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
// Arrange
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.ProjectLocatorFactory = (sp) => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) =>
{
return 1; // Simulate a build failure
}
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
// Act
var result = command.Parse("deploy --apphost valid.csproj");
var exitCode = await result.InvokeAsync().DefaultTimeout();
// Assert
Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode); // Ensure the command fails
}
[Fact]
public async Task DeployCommandSucceedsWithoutOutputPath()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
// Arrange
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.ProjectLocatorFactory = (sp) => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner
{
// Simulate a successful build
BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0,
// Simulate a successful app host information retrieval
GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) =>
{
return (0, true, VersionHelper.GetDefaultTemplateVersion()); // Compatible app host with backchannel support
},
// Simulate apphost running successfully and establishing a backchannel
RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) =>
{
Assert.True(options.NoLaunchProfile);
// Verify that --output-path is NOT included when not specified
Assert.DoesNotContain("--output-path", args);
// Verify that --step deploy is passed by default
Assert.Contains("--step", args);
Assert.Contains("deploy", args);
var deployModeCompleted = new TaskCompletionSource();
var backchannel = new TestAppHostBackchannel
{
RequestStopAsyncCalled = deployModeCompleted
};
backchannelCompletionSource?.SetResult(backchannel);
await deployModeCompleted.Task.DefaultTimeout();
return 0; // Simulate successful run
}
};
return runner;
};
options.PublishCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestDeployCommandPrompter(interactionService);
return prompter;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
// Act
var result = command.Parse("deploy");
var exitCode = await result.InvokeAsync().DefaultTimeout();
// Assert
Assert.Equal(0, exitCode); // Ensure the command succeeds
}
[Fact]
public async Task DeployCommandSucceedsEndToEnd()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var expectedAspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, ".home", ".aspire");
// Arrange
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.ProjectLocatorFactory = (sp) => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner
{
// Simulate a successful build
BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0,
// Simulate a successful app host information retrieval
GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) =>
{
return (0, true, VersionHelper.GetDefaultTemplateVersion()); // Compatible app host with backchannel support
},
// Simulate apphost running successfully and establishing a backchannel
RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) =>
{
Assert.True(options.NoLaunchProfile);
Assert.NotNull(env);
Assert.Equal(expectedAspireHome, env[KnownConfigNames.AspireHome]);
// Verify the complete set of expected arguments for deploy command
Assert.Contains("--operation", args);
Assert.Contains("publish", args);
// Verify that --step deploy is passed by default
Assert.Contains("--step", args);
Assert.Contains("deploy", args);
var deployModeCompleted = new TaskCompletionSource();
var backchannel = new TestAppHostBackchannel
{
RequestStopAsyncCalled = deployModeCompleted
};
backchannelCompletionSource?.SetResult(backchannel);
await deployModeCompleted.Task.DefaultTimeout();
return 0; // Simulate successful run
}
};
return runner;
};
options.PublishCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestDeployCommandPrompter(interactionService);
return prompter;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
// Act
var result = command.Parse("deploy");
var exitCode = await result.InvokeAsync().DefaultTimeout();
// Assert
Assert.Equal(0, exitCode); // Ensure the command succeeds
}
[Fact]
public async Task DeployCommandIncludesDeployFlagInArguments()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
// Use a cross-platform path for testing
var testOutputPath = Path.Combine(Path.GetTempPath(), "test");
// Arrange
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.ProjectLocatorFactory = (sp) => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner
{
// Simulate a successful build
BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0,
// Simulate a successful app host information retrieval
GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) =>
{
return (0, true, VersionHelper.GetDefaultTemplateVersion());
},
// Simulate apphost running and verify --step deploy flag is passed
RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) =>
{
Assert.Contains("--operation", args);
Assert.Contains("publish", args);
// When output path is explicitly provided, it should be included
Assert.Contains("--output-path", args);
Assert.Contains(testOutputPath, args);
// Verify that --step deploy is passed by default
Assert.Contains("--step", args);
Assert.Contains("deploy", args);
var deployModeCompleted = new TaskCompletionSource();
var backchannel = new TestAppHostBackchannel
{
RequestStopAsyncCalled = deployModeCompleted
};
backchannelCompletionSource?.SetResult(backchannel);
await deployModeCompleted.Task.DefaultTimeout();
return 0;
}
};
return runner;
};
options.PublishCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestDeployCommandPrompter(interactionService);
return prompter;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
// Act
var result = command.Parse($"deploy --output-path {testOutputPath}");
var exitCode = await result.InvokeAsync().DefaultTimeout();
// Assert
Assert.Equal(0, exitCode);
}
[Fact]
public async Task DeployCommandReturnsNonZeroExitCodeWhenDeploymentFails()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
// Arrange
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.ProjectLocatorFactory = (sp) => new TestProjectLocator();
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner
{
// Simulate a successful build
BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0,
// Simulate a successful app host information retrieval
GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) =>
{
return (0, true, VersionHelper.GetDefaultTemplateVersion()); // Compatible app host with backchannel support
},
// Simulate apphost running but deployment fails
RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) =>
{
var deployModeCompleted = new TaskCompletionSource();
var backchannel = new TestAppHostBackchannel
{
RequestStopAsyncCalled = deployModeCompleted,
GetPublishingActivitiesAsyncCallback = GetFailedDeploymentActivities
};
backchannelCompletionSource?.SetResult(backchannel);
await deployModeCompleted.Task.DefaultTimeout();
return 0; // AppHost exits with 0 even though deployment failed
}
};
return runner;
};
options.PublishCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestDeployCommandPrompter(interactionService);
return prompter;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
// Act
var result = command.Parse("deploy");
var exitCode = await result.InvokeAsync().DefaultTimeout();
// Assert
Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode); // Ensure the command returns a non-zero exit code
static async IAsyncEnumerable<PublishingActivity> GetFailedDeploymentActivities([EnumeratorCancellation] CancellationToken cancellationToken)
{
// Simulate a deployment step starting
yield return new PublishingActivity
{
Type = PublishingActivityTypes.Step,
Data = new PublishingActivityData
{
Id = "deploy-step",
StatusText = "Deploying Azure resources",
CompletionState = CompletionStates.InProgress,
StepId = null
}
};
// Simulate a task that fails
yield return new PublishingActivity
{
Type = PublishingActivityTypes.Task,
Data = new PublishingActivityData
{
Id = "deploy-postgres",
StatusText = "Deploying postgres: 0%",
CompletionState = CompletionStates.InProgress,
StepId = "deploy-step"
}
};
yield return new PublishingActivity
{
Type = PublishingActivityTypes.Task,
Data = new PublishingActivityData
{
Id = "deploy-postgres",
StatusText = "Deploying postgres failed",
CompletionMessage = "Failed to deploy Azure resources",
CompletionState = CompletionStates.CompletedWithError,
StepId = "deploy-step"
}
};
// Simulate the step completing with error
yield return new PublishingActivity
{
Type = PublishingActivityTypes.Step,
Data = new PublishingActivityData
{
Id = "deploy-step",
StatusText = "Failed to deploy Azure resources",
CompletionState = CompletionStates.CompletedWithError,
StepId = null
}
};
// Simulate publish complete with error
yield return new PublishingActivity
{
Type = PublishingActivityTypes.PublishComplete,
Data = new PublishingActivityData
{
Id = "publish-complete",
StatusText = "Deployment completed with errors",
CompletionState = CompletionStates.CompletedWithError,
StepId = null
}
};
}
}
}
internal sealed class TestDeployCommandPrompter(IInteractionService interactionService) : PublishCommandPrompter(interactionService)
{
public Func<IEnumerable<string>, string>? PromptForPublisherCallback { get; set; }
public override Task<string> PromptForPublisherAsync(IEnumerable<string> publishers, CancellationToken cancellationToken)
{
return PromptForPublisherCallback switch
{
{ } callback => Task.FromResult(callback(publishers)),
_ => Task.FromResult(publishers.First()) // Default to the first publisher if no callback is provided.
};
}
}