// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Resources;
using Hex1b.Automation;
using Xunit;
using Aspire.TestUtilities;
namespace Aspire.Cli.EndToEnd.Tests;
/// <summary>
/// End-to-end tests for aspire resource command execution.
/// </summary>
public sealed class ResourceCommandTests(ITestOutputHelper output)
{
[Fact]
[CaptureWorkspaceOnFailure]
public async Task ResourceCommand_SetAndDeleteParameterUpdatesDescribeOutput()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var projectSuffix = Guid.NewGuid().ToString("N")[..6];
var projectName = $"ResourceParameterCmdApp_{projectSuffix}";
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, template: AspireTemplate.EmptyAppHost);
await auto.TypeAsync($"cd {projectName}");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName, "apphost.cs");
var content = File.ReadAllText(appHostFilePath);
var sdkLine = content.Split('\n', 2)[0].TrimEnd('\r');
var newContent = $$"""
{{sdkLine}}
var builder = DistributedApplication.CreateBuilder(args);
builder.AddParameter("greeting");
builder.Build().Run();
""";
File.WriteAllText(appHostFilePath, newContent);
await auto.TypeAsync("aspire start");
await auto.EnterAsync();
await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3));
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire describe greeting --format json > greeting-unset.json");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
await auto.TypeAsync("jq -er '.resources[0].state' greeting-unset.json");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("ValueMissing", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire resource greeting set-parameter --value 'Hello world'");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("Resource 'greeting' set successfully.", timeout: TimeSpan.FromSeconds(30));
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
await auto.TypeAsync("aspire describe greeting --format json > greeting-set.json");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
await auto.TypeAsync("jq -er '.resources[0].state' greeting-set.json");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("Running", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("jq -er '.resources[0].properties.Value' greeting-set.json");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("Hello world", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire resource greeting delete-parameter");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("Resource 'greeting' deleted successfully.", timeout: TimeSpan.FromSeconds(30));
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
await auto.TypeAsync("aspire describe greeting --format json > greeting-deleted.json");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
await auto.TypeAsync("jq -er '.resources[0].state' greeting-deleted.json");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("ValueMissing", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire stop");
await auto.EnterAsync();
await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1));
await auto.WaitForSuccessPromptAsync(counter);
}
[Fact]
[CaptureWorkspaceOnFailure]
[QuarantinedTest("https://github.com/microsoft/aspire/issues/17485")]
public async Task ResourceCommand_FailsWhenInteractionServiceIsRequired()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var projectSuffix = Guid.NewGuid().ToString("N")[..6];
var projectName = $"ResourceCmdApp_{projectSuffix}";
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, template: AspireTemplate.EmptyAppHost);
await auto.TypeAsync($"cd {projectName}");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Read the generated apphost.cs so we can extract the #:sdk line with the
// resolved version, then replace the entire file with a minimal AppHost
// that has a parameter resource and a command that uses IInteractionService.
// Using a parameter instead of a container avoids Docker container teardown
// flakiness during `aspire stop` in CI (see #17485).
var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName, "apphost.cs");
var content = File.ReadAllText(appHostFilePath);
// Extract the first line (#:sdk directive) so the replacement uses the same SDK version.
var sdkLine = content.Split('\n', 2)[0].TrimEnd('\r');
var newContent = $$"""
{{sdkLine}}
var builder = DistributedApplication.CreateBuilder(args);
var resource = builder.AddParameter("test-resource");
resource.WithCommand(
name: "needs-interaction",
displayName: "Needs interaction",
executeCommand: async context =>
{
var interactionService = (IInteractionService)context.ServiceProvider.GetService(typeof(IInteractionService))!;
try
{
// This should throw because InteractionService is not available in non-interactive mode.
// Bound the wait to avoid hanging the E2E run if behavior regresses.
_ = await interactionService.PromptInputAsync(
title: "Prompt title",
message: "Prompt message",
inputLabel: "Name",
placeHolder: "placeholder").WaitAsync(TimeSpan.FromSeconds(10));
// We're looking for a failure. Treat a successful prompt completion as a test failure since that would
// indicate the interaction service is available when it shouldn't be.
return CommandResults.Success("Prompt unexpectedly completed without throwing.");
}
catch (TimeoutException)
{
// We're looking for a failure, and the most likely failure mode if the interaction service is incorrectly
// available would be that the prompt hangs waiting for input that will never come.
// Treat a timeout as a success since that indicates the interaction service is not available to show the prompt.
return CommandResults.Success("Prompt timed out after 10 seconds.");
}
});
builder.Build().Run();
""";
File.WriteAllText(appHostFilePath, newContent);
await auto.TypeAsync("aspire start");
await auto.EnterAsync();
await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3));
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire resource test-resource needs-interaction");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("Failed to execute command 'needs-interaction' on resource 'test-resource'", timeout: TimeSpan.FromSeconds(30));
await auto.WaitUntilTextAsync("InteractionService is not available", timeout: TimeSpan.FromSeconds(30));
await auto.WaitUntilTextAsync("See logs at", timeout: TimeSpan.FromSeconds(30));
await auto.WaitUntilTextAsync("See AppHost logs at", timeout: TimeSpan.FromSeconds(30));
await auto.WaitForAnyPromptAsync(counter, timeout: TimeSpan.FromSeconds(30));
await auto.TypeAsync($"if [ $? -eq {CliExitCodes.FailedToExecuteResourceCommand} ]; then echo RESOURCE_CMD_EXIT_CODE_CORRECT; else echo RESOURCE_CMD_UNEXPECTED_EXIT_CODE; fi");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("RESOURCE_CMD_EXIT_CODE_CORRECT", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire stop");
await auto.EnterAsync();
await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1));
await auto.WaitForSuccessPromptAsync(counter);
}
[Fact]
[CaptureWorkspaceOnFailure]
public async Task ResourceCommand_FailedExec_ShowsLogPathAndLogHasEntries()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var projectSuffix = Guid.NewGuid().ToString("N")[..6];
var projectName = $"ResourceCmdLogApp_{projectSuffix}";
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, enableDcpDiagnostics: true);
await auto.InstallAspireCliAsync(strategy, counter);
await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.EmptyAppHost);
await auto.TypeAsync($"cd {projectName}");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Replace the generated apphost.cs with a minimal AppHost that has a
// command writing to context.Logger before returning a failure result.
var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName, "apphost.cs");
var content = File.ReadAllText(appHostFilePath);
// Extract the first line (#:sdk directive) so the replacement uses the same SDK version.
var sdkLine = content.Split('\n', 2)[0].TrimEnd('\r');
var newContent = $$"""
{{sdkLine}}
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
var builder = DistributedApplication.CreateBuilder(args);
var cache = builder.AddContainer("cache", "redis");
cache.WithCommand(
name: "fail-with-log",
displayName: "Fail with log",
executeCommand: context =>
{
var logger = context.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogInformation("CUSTOM_E2E_LOG_ENTRY_FOR_VERIFICATION");
return Task.FromResult(CommandResults.Failure("Command failed intentionally."));
});
builder.Build().Run();
""";
File.WriteAllText(appHostFilePath, newContent);
// Start the AppHost in detached mode.
await auto.TypeAsync("aspire start");
await auto.EnterAsync();
await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3));
await auto.WaitForSuccessPromptAsync(counter);
// Run the failing resource command, capturing stdout and stderr so we can extract the
// AppHost log path from the "See AppHost logs at <path>" message.
await auto.TypeAsync("aspire resource cache fail-with-log > /tmp/resource-cmd-output.txt 2>&1");
await auto.EnterAsync();
await auto.WaitForAnyPromptAsync(counter, timeout: TimeSpan.FromSeconds(30));
await auto.TypeAsync($"if [ $? -eq {CliExitCodes.FailedToExecuteResourceCommand} ]; then echo RESOURCE_CMD_EXIT_CODE_CORRECT; else echo RESOURCE_CMD_UNEXPECTED_EXIT_CODE; fi");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("RESOURCE_CMD_EXIT_CODE_CORRECT", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
// Extract the AppHost log file path from the captured output and verify
// it exists and is non-empty.
// Extract the AppHost log file path from the captured output. The CLI
// wraps paths in OSC 8 terminal hyperlinks (\e]8;...;\e\\path\e]8;;\e\\),
// so we must strip those escape sequences before extracting the path.
await auto.RunCommandAsync(
"APPHOST_LOG=$(sed 's/\\x1b\\][^\\x1b]*\\x1b\\\\//g' /tmp/resource-cmd-output.txt | grep 'See AppHost logs at' | sed 's/.*See AppHost logs at //')",
counter);
// Debug: Show what was captured in stderr and the extracted APPHOST_LOG value.
// Run these in a single command with || true so they always complete even if one step fails.
await auto.RunCommandAsync(
"echo '=== Contents of /tmp/resource-cmd-output.txt ===' && cat /tmp/resource-cmd-output.txt && echo && echo '=== APPHOST_LOG value ===' && echo \"$APPHOST_LOG\" && echo '=== End debug output ===' || true",
counter);
await auto.RunCommandAsync(
"test -n \"$APPHOST_LOG\" && test -s \"$APPHOST_LOG\"",
counter,
TimeSpan.FromSeconds(10));
// Verify the log file contains the custom log entry written by the
// command handler via ILogger before returning the failure result.
await auto.RunCommandAsync(
"grep -q 'CUSTOM_E2E_LOG_ENTRY_FOR_VERIFICATION' \"$APPHOST_LOG\"",
counter,
TimeSpan.FromSeconds(10));
// Stop the AppHost.
await auto.TypeAsync("aspire stop");
await auto.EnterAsync();
await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1));
await auto.WaitForSuccessPromptAsync(counter);
}
[Fact]
[CaptureWorkspaceOnFailure]
public async Task ResourceCommand_JsonResult_WritesOnlyJsonToStdoutAndStatusToStderr()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var projectSuffix = Guid.NewGuid().ToString("N")[..6];
var projectName = $"ResourceCmdJsonApp_{projectSuffix}";
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, template: AspireTemplate.EmptyAppHost);
await auto.TypeAsync($"cd {projectName}");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Replace the generated apphost.cs with a minimal AppHost that has a
// command returning a JSON result.
var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName, "apphost.cs");
var content = File.ReadAllText(appHostFilePath);
var sdkLine = content.Split('\n', 2)[0].TrimEnd('\r');
var newContent = $$"""
{{sdkLine}}
var builder = DistributedApplication.CreateBuilder(args);
var resource = builder.AddParameter("test-resource");
resource.WithCommand(
name: "get-info",
displayName: "Get info",
executeCommand: context =>
{
var json = "{\"status\":\"healthy\",\"version\":\"1.0.0\",\"items\":[1,2,3]}";
return Task.FromResult(CommandResults.Success("Info retrieved", json, CommandResultFormat.Json));
});
builder.Build().Run();
""";
File.WriteAllText(appHostFilePath, newContent);
await auto.TypeAsync("aspire start");
await auto.EnterAsync();
await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3));
await auto.WaitForSuccessPromptAsync(counter);
// Run the resource command, separating stdout and stderr into distinct files.
await auto.TypeAsync("aspire resource test-resource get-info > /tmp/cmd-stdout.txt 2> /tmp/cmd-stderr.txt");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
// Assert that stdout contains only valid JSON parsable by jq.
await auto.TypeAsync("jq . /tmp/cmd-stdout.txt > /dev/null");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Assert that the JSON content matches what the command returned.
await auto.TypeAsync("jq -r '.status' /tmp/cmd-stdout.txt");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("healthy", timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
// Assert that stderr has content (status/progress messages from the CLI).
await auto.TypeAsync("test -s /tmp/cmd-stderr.txt");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire stop");
await auto.EnterAsync();
await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1));
await auto.WaitForSuccessPromptAsync(counter);
}
}