From 5f7901c58f5795df55ead9c06f73ad3211dc10bb Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 27 Mar 2026 11:02:37 -0700 Subject: [PATCH] Add command result support for resource commands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- playground/Stress/Stress.AppHost/Program.cs | 47 +++++++- .../Commands/ResourceCommandHelper.cs | 21 +++- .../Mcp/Tools/ExecuteResourceCommandTool.cs | 29 ++++- .../Model/DashboardCommandExecutor.cs | 15 ++- .../Model/ResourceCommandResponseViewModel.cs | 23 ++++ .../ServiceClient/Partials.cs | 4 +- .../ResourceCommandAnnotation.cs | 46 ++++++++ .../ResourceCommandService.cs | 8 +- .../AuxiliaryBackchannelRpcTarget.cs | 4 +- .../Backchannel/BackchannelDataTypes.cs | 10 ++ .../Dashboard/DashboardService.cs | 14 ++- .../Dashboard/DashboardServiceData.cs | 8 +- .../Dashboard/proto/dashboard_service.proto | 8 ++ .../Commands/ResourceCommandHelperTests.cs | 105 ++++++++++++++++++ .../Mcp/ExecuteResourceCommandToolTests.cs | 57 +++++++++- .../TestServices/TestInteractionService.cs | 6 + .../Pages/ConsoleLogsTests.cs | 2 +- ...TwoPassScanningGeneratedAspire.verified.go | 13 +++ ...oPassScanningGeneratedAspire.verified.java | 39 +++++++ ...TwoPassScanningGeneratedAspire.verified.py | 4 + ...TwoPassScanningGeneratedAspire.verified.rs | 28 +++++ ...TwoPassScanningGeneratedAspire.verified.ts | 9 ++ .../ResourceCommandServiceTests.cs | 90 +++++++++++++++ 23 files changed, 567 insertions(+), 23 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs diff --git a/playground/Stress/Stress.AppHost/Program.cs b/playground/Stress/Stress.AppHost/Program.cs index f32a197eeec..7c621547f37 100644 --- a/playground/Stress/Stress.AppHost/Program.cs +++ b/playground/Stress/Stress.AppHost/Program.cs @@ -1,6 +1,7 @@ // 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 Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -124,7 +125,51 @@ await ExecuteCommandForAllResourcesAsync(c.ServiceProvider, KnownResourceCommands.StartCommand, c.CancellationToken); return CommandResults.Success(); }, - commandOptions: new() { IconName = "Play", IconVariant = IconVariant.Filled }); + commandOptions: new() { IconName = "Play", IconVariant = IconVariant.Filled }) + .WithCommand( + name: "generate-token", + displayName: "Generate Token", + executeCommand: (c) => + { + var token = new + { + accessToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + Convert.ToBase64String(Guid.NewGuid().ToByteArray()), + tokenType = "Bearer", + expiresIn = 3600, + scope = "api.read api.write", + issuedAt = DateTime.UtcNow + }; + var json = JsonSerializer.Serialize(token, new JsonSerializerOptions { WriteIndented = true }); + return Task.FromResult(CommandResults.Success(json, CommandResultFormat.Json)); + }, + commandOptions: new() { IconName = "Key", Description = "Generate a temporary access token" }) + .WithCommand( + name: "get-connection-string", + displayName: "Get Connection String", + executeCommand: (c) => + { + var connectionString = $"Server=localhost,1433;Database=StressDb;User Id=sa;Password={Guid.NewGuid():N};TrustServerCertificate=true"; + return Task.FromResult(CommandResults.Success(connectionString, CommandResultFormat.Text)); + }, + commandOptions: new() { IconName = "LinkMultiple", Description = "Get the connection string for this resource" }) + .WithCommand( + name: "validate-config", + displayName: "Validate Config", + executeCommand: (c) => + { + var errors = new { errors = new[] { new { field = "connectionString", message = "Invalid host" }, new { field = "timeout", message = "Must be positive" } } }; + var json = JsonSerializer.Serialize(errors, new JsonSerializerOptions { WriteIndented = true }); + return Task.FromResult(CommandResults.Failure("Validation failed", json, CommandResultFormat.Json)); + }, + commandOptions: new() { IconName = "Warning", Description = "Validate resource configuration (always fails with details)" }) + .WithCommand( + name: "check-health", + displayName: "Check Health", + executeCommand: (c) => + { + return Task.FromResult(CommandResults.Failure("Health check failed", "Connection refused: ECONNREFUSED 127.0.0.1:5432\nRetries exhausted after 3 attempts", CommandResultFormat.Text)); + }, + commandOptions: new() { IconName = "HeartBroken", Description = "Check resource health (always fails with details)" }); #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging diff --git a/src/Aspire.Cli/Commands/ResourceCommandHelper.cs b/src/Aspire.Cli/Commands/ResourceCommandHelper.cs index 4db1f3753a3..18689d0ef8f 100644 --- a/src/Aspire.Cli/Commands/ResourceCommandHelper.cs +++ b/src/Aspire.Cli/Commands/ResourceCommandHelper.cs @@ -59,6 +59,9 @@ public static async Task ExecuteGenericCommandAsync( { logger.LogDebug("Executing command '{CommandName}' on resource '{ResourceName}'", commandName, resourceName); + // Route status messages to stderr so command results can be piped (e.g., | jq) + interactionService.Console = ConsoleOutput.Error; + var response = await interactionService.ShowStatusAsync( $"Executing command '{commandName}' on resource '{resourceName}'...", async () => await connection.ExecuteResourceCommandAsync(resourceName, commandName, cancellationToken)); @@ -66,7 +69,6 @@ public static async Task ExecuteGenericCommandAsync( if (response.Success) { interactionService.DisplaySuccess($"Command '{commandName}' executed successfully on resource '{resourceName}'."); - return ExitCodeConstants.Success; } else if (response.Canceled) { @@ -77,8 +79,14 @@ public static async Task ExecuteGenericCommandAsync( { var errorMessage = GetFriendlyErrorMessage(response.ErrorMessage); interactionService.DisplayError($"Failed to execute command '{commandName}' on resource '{resourceName}': {errorMessage}"); - return ExitCodeConstants.FailedToExecuteResourceCommand; } + + if (response.Result is not null) + { + interactionService.DisplayRawText(response.Result, ConsoleOutput.Standard); + } + + return response.Success ? ExitCodeConstants.Success : ExitCodeConstants.FailedToExecuteResourceCommand; } private static int HandleResponse( @@ -92,7 +100,6 @@ private static int HandleResponse( if (response.Success) { interactionService.DisplaySuccess($"Resource '{resourceName}' {pastTenseVerb} successfully."); - return ExitCodeConstants.Success; } else if (response.Canceled) { @@ -103,8 +110,14 @@ private static int HandleResponse( { var errorMessage = GetFriendlyErrorMessage(response.ErrorMessage); interactionService.DisplayError($"Failed to {baseVerb} resource '{resourceName}': {errorMessage}"); - return ExitCodeConstants.FailedToExecuteResourceCommand; } + + if (response.Result is not null) + { + interactionService.DisplayRawText(response.Result, ConsoleOutput.Standard); + } + + return response.Success ? ExitCodeConstants.Success : ExitCodeConstants.FailedToExecuteResourceCommand; } private static string GetFriendlyErrorMessage(string? errorMessage) diff --git a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs index 4e8ec36df79..916c1d95dd1 100644 --- a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs @@ -74,9 +74,19 @@ public override async ValueTask CallToolAsync(CallToolContext co if (response.Success) { + var content = new List + { + new() { Text = $"Command '{commandName}' executed successfully on resource '{resourceName}'." } + }; + + if (response.Result is not null) + { + content.Add(new TextContentBlock { Text = response.Result }); + } + return new CallToolResult { - Content = [new TextContentBlock { Text = $"Command '{commandName}' executed successfully on resource '{resourceName}'." }] + Content = [.. content] }; } else if (response.Canceled) @@ -86,7 +96,22 @@ public override async ValueTask CallToolAsync(CallToolContext co else { var message = response.ErrorMessage is { Length: > 0 } ? response.ErrorMessage : "Unknown error. See logs for details."; - throw new McpProtocolException($"Command '{commandName}' failed for resource '{resourceName}': {message}", McpErrorCode.InternalError); + + var content = new List + { + new() { Text = $"Command '{commandName}' failed for resource '{resourceName}': {message}" } + }; + + if (response.Result is not null) + { + content.Add(new TextContentBlock { Text = response.Result }); + } + + return new CallToolResult + { + IsError = true, + Content = [.. content] + }; } } catch (McpProtocolException) diff --git a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs index dad49fe91d8..3eaf9a60e04 100644 --- a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs +++ b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using Aspire.Dashboard.Components.Dialogs; using Aspire.Dashboard.Telemetry; using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; @@ -13,7 +14,7 @@ namespace Aspire.Dashboard.Model; public sealed class DashboardCommandExecutor( IDashboardClient dashboardClient, - IDialogService dialogService, + DashboardDialogService dialogService, IToastService toastService, IStringLocalizer loc, NavigationManager navigationManager, @@ -168,6 +169,18 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel toastParameters.OnPrimaryAction = EventCallback.Factory.Create(this, () => navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: getResourceName(resource)))); } + if (response.Result is not null) + { + var fixedFormat = response.ResultFormat == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; + await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions + { + DialogService = dialogService, + ValueDescription = command.GetDisplayName(), + Value = response.Result, + FixedFormat = fixedFormat + }).ConfigureAwait(false); + } + if (!toastClosed) { // Extend cancel time. diff --git a/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs b/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs index 64f4dc89412..1641e338231 100644 --- a/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs @@ -7,6 +7,8 @@ public class ResourceCommandResponseViewModel { public required ResourceCommandResponseKind Kind { get; init; } public string? ErrorMessage { get; init; } + public string? Result { get; init; } + public CommandResultFormat ResultFormat { get; init; } } // Must be kept in sync with ResourceCommandResponseKind in the resource_service.proto file @@ -17,3 +19,24 @@ public enum ResourceCommandResponseKind Failed = 2, Cancelled = 3 } + +/// +/// Specifies the format of a command result. +/// +public enum CommandResultFormat +{ + /// + /// No result data. + /// + None = 0, + + /// + /// Plain text result. + /// + Text = 1, + + /// + /// JSON result. + /// + Json = 2 +} diff --git a/src/Aspire.Dashboard/ServiceClient/Partials.cs b/src/Aspire.Dashboard/ServiceClient/Partials.cs index d2d09d8debb..6185f5b0004 100644 --- a/src/Aspire.Dashboard/ServiceClient/Partials.cs +++ b/src/Aspire.Dashboard/ServiceClient/Partials.cs @@ -199,7 +199,9 @@ public ResourceCommandResponseViewModel ToViewModel() return new ResourceCommandResponseViewModel() { ErrorMessage = ErrorMessage, - Kind = (Dashboard.Model.ResourceCommandResponseKind)Kind + Kind = (Dashboard.Model.ResourceCommandResponseKind)Kind, + Result = HasResult ? Result : null, + ResultFormat = (Dashboard.Model.CommandResultFormat)ResultFormat }; } } diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs index e06e8ca1021..eeaadfae247 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs @@ -114,6 +114,27 @@ public enum IconVariant Filled } +/// +/// Specifies the format of a command result. +/// +public enum CommandResultFormat +{ + /// + /// No result data. + /// + None = 0, + + /// + /// Plain text result. + /// + Text = 1, + + /// + /// JSON result. + /// + Json = 2 +} + /// /// A factory for . /// @@ -124,12 +145,27 @@ public static class CommandResults /// public static ExecuteCommandResult Success() => new() { Success = true }; + /// + /// Produces a success result with result data. + /// + /// The result data. + /// The format of the result data. Defaults to . + public static ExecuteCommandResult Success(string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = true, Result = result, ResultFormat = resultFormat }; + /// /// Produces an unsuccessful result with an error message. /// /// An optional error message. public static ExecuteCommandResult Failure(string? errorMessage = null) => new() { Success = false, ErrorMessage = errorMessage }; + /// + /// Produces an unsuccessful result with an error message and result data. + /// + /// The error message. + /// The result data. + /// The format of the result data. Defaults to . + public static ExecuteCommandResult Failure(string errorMessage, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = false, ErrorMessage = errorMessage, Result = result, ResultFormat = resultFormat }; + /// /// Produces a canceled result. /// @@ -162,6 +198,16 @@ public sealed class ExecuteCommandResult /// An optional error message that can be set when the command is unsuccessful. /// public string? ErrorMessage { get; init; } + + /// + /// An optional result value produced by the command. + /// + public string? Result { get; init; } + + /// + /// The format of the value. + /// + public CommandResultFormat ResultFormat { get; init; } } /// diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs index 2446ae63797..ef79a7ad37e 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs @@ -106,7 +106,13 @@ public async Task ExecuteCommandAsync(IResource resource, if (failures.Count == 0 && cancellations.Count == 0) { - return new ExecuteCommandResult { Success = true }; + var successWithResult = results.FirstOrDefault(r => r.Result is not null); + return new ExecuteCommandResult + { + Success = true, + Result = successWithResult?.Result, + ResultFormat = successWithResult?.ResultFormat ?? CommandResultFormat.None + }; } else if (failures.Count == 0 && cancellations.Count > 0) { diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index f9a560da3d8..15bb56b450a 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -218,7 +218,9 @@ public async Task ExecuteResourceCommandAsync(Ex { Success = result.Success, Canceled = result.Canceled, - ErrorMessage = result.ErrorMessage + ErrorMessage = result.ErrorMessage, + Result = result.Result, + ResultFormat = result.ResultFormat.ToString().ToLowerInvariant() }; } diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 2ca2154b73d..d83a89f9262 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -285,6 +285,16 @@ internal sealed class ExecuteResourceCommandResponse /// Gets the error message if the command failed. /// public string? ErrorMessage { get; init; } + + /// + /// Gets the result data produced by the command. + /// + public string? Result { get; init; } + + /// + /// Gets the format of the result data (e.g. "none", "text", "json"). + /// + public string? ResultFormat { get; init; } } #endregion diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index d3b3faad7bc..31d0fad89bc 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -360,7 +360,7 @@ async Task WatchResourceConsoleLogsInternal(bool suppressFollow, CancellationTok public override async Task ExecuteResourceCommand(ResourceCommandRequest request, ServerCallContext context) { - var (result, errorMessage) = await serviceData.ExecuteCommandAsync(request.ResourceName, request.CommandName, context.CancellationToken).ConfigureAwait(false); + var (result, errorMessage, commandResult, resultFormat) = await serviceData.ExecuteCommandAsync(request.ResourceName, request.CommandName, context.CancellationToken).ConfigureAwait(false); var responseKind = result switch { ExecuteCommandResultType.Success => ResourceCommandResponseKind.Succeeded, @@ -369,11 +369,19 @@ public override async Task ExecuteResourceCommand(Resou _ => ResourceCommandResponseKind.Undefined }; - return new ResourceCommandResponse + var response = new ResourceCommandResponse { Kind = responseKind, - ErrorMessage = errorMessage ?? string.Empty + ErrorMessage = errorMessage ?? string.Empty, + ResultFormat = (Aspire.DashboardService.Proto.V1.CommandResultFormat)resultFormat }; + + if (commandResult is not null) + { + response.Result = commandResult; + } + + return response; } private async Task ExecuteAsync(Func execute, ServerCallContext serverCallContext) diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index 6aa606e9496..7bcb6d25855 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -94,21 +94,21 @@ public void Dispose() _cts.Dispose(); } - internal async Task<(ExecuteCommandResultType result, string? errorMessage)> ExecuteCommandAsync(string resourceId, string type, CancellationToken cancellationToken) + internal async Task<(ExecuteCommandResultType result, string? errorMessage, string? commandResult, ApplicationModel.CommandResultFormat resultFormat)> ExecuteCommandAsync(string resourceId, string type, CancellationToken cancellationToken) { try { var result = await _resourceCommandService.ExecuteCommandAsync(resourceId, type, cancellationToken).ConfigureAwait(false); if (result.Canceled) { - return (ExecuteCommandResultType.Canceled, result.ErrorMessage); + return (ExecuteCommandResultType.Canceled, result.ErrorMessage, null, ApplicationModel.CommandResultFormat.None); } - return (result.Success ? ExecuteCommandResultType.Success : ExecuteCommandResultType.Failure, result.ErrorMessage); + return (result.Success ? ExecuteCommandResultType.Success : ExecuteCommandResultType.Failure, result.ErrorMessage, result.Result, result.ResultFormat); } catch { // Note: Exception is already logged in the command executor. - return (ExecuteCommandResultType.Failure, "Unhandled exception thrown while executing command."); + return (ExecuteCommandResultType.Failure, "Unhandled exception thrown while executing command.", null, ApplicationModel.CommandResultFormat.None); } } diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto index a7f7fe37998..bd2e1015eb4 100644 --- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto @@ -92,6 +92,14 @@ enum ResourceCommandResponseKind { message ResourceCommandResponse { ResourceCommandResponseKind kind = 1; optional string error_message = 2; + optional string result = 3; + CommandResultFormat result_format = 4; +} + +enum CommandResultFormat { + COMMAND_RESULT_FORMAT_NONE = 0; + COMMAND_RESULT_FORMAT_TEXT = 1; + COMMAND_RESULT_FORMAT_JSON = 2; } //////////////////////////////////////////// diff --git a/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs b/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs new file mode 100644 index 00000000000..875390396f7 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs @@ -0,0 +1,105 @@ +// 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.Backchannel; +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.TestServices; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.Commands; + +public class ResourceCommandHelperTests +{ + [Fact] + public async Task ExecuteGenericCommandAsync_WithResult_OutputsRawText() + { + var connection = new TestAppHostAuxiliaryBackchannel + { + ExecuteResourceCommandResult = new ExecuteResourceCommandResponse + { + Success = true, + Result = "{\"items\": [\"a\", \"b\"]}", + ResultFormat = "json" + } + }; + + string? capturedRawText = null; + var interactionService = new TestInteractionService + { + DisplayRawTextCallback = (text) => capturedRawText = text + }; + + var exitCode = await ResourceCommandHelper.ExecuteGenericCommandAsync( + connection, + interactionService, + NullLogger.Instance, + "myResource", + "generate-token", + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.NotNull(capturedRawText); + // Verify the raw result is passed through without any escaping + Assert.Equal("{\"items\": [\"a\", \"b\"]}", capturedRawText); + } + + [Fact] + public async Task ExecuteGenericCommandAsync_WithoutResult_DoesNotCallDisplayMessage() + { + var connection = new TestAppHostAuxiliaryBackchannel + { + ExecuteResourceCommandResult = new ExecuteResourceCommandResponse { Success = true } + }; + + var displayRawTextCalled = false; + var interactionService = new TestInteractionService + { + DisplayRawTextCallback = (_) => displayRawTextCalled = true + }; + + var exitCode = await ResourceCommandHelper.ExecuteGenericCommandAsync( + connection, + interactionService, + NullLogger.Instance, + "myResource", + "start", + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.False(displayRawTextCalled); + } + + [Fact] + public async Task ExecuteGenericCommandAsync_ErrorWithResult_OutputsRawText() + { + var connection = new TestAppHostAuxiliaryBackchannel + { + ExecuteResourceCommandResult = new ExecuteResourceCommandResponse + { + Success = false, + ErrorMessage = "Validation failed", + Result = "{\"errors\": [\"invalid host\"]}", + ResultFormat = "json" + } + }; + + string? capturedRawText = null; + var interactionService = new TestInteractionService + { + DisplayRawTextCallback = (text) => capturedRawText = text + }; + + var exitCode = await ResourceCommandHelper.ExecuteGenericCommandAsync( + connection, + interactionService, + NullLogger.Instance, + "myResource", + "validate-config", + CancellationToken.None).DefaultTimeout(); + + Assert.NotEqual(0, exitCode); + Assert.NotNull(capturedRawText); + Assert.Equal("{\"errors\": [\"invalid host\"]}", capturedRawText); + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs index 7c8774b193e..8926e7a97d4 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs @@ -61,7 +61,7 @@ public async Task ExecuteResourceCommandTool_ReturnsSuccess_WhenCommandExecutedS } [Fact] - public async Task ExecuteResourceCommandTool_ThrowsException_WhenCommandFails() + public async Task ExecuteResourceCommandTool_ReturnsError_WhenCommandFails() { var monitor = new TestAuxiliaryBackchannelMonitor(); var connection = new TestAppHostAuxiliaryBackchannel @@ -76,10 +76,10 @@ public async Task ExecuteResourceCommandTool_ThrowsException_WhenCommandFails() var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); - var exception = await Assert.ThrowsAsync( - () => tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("nonexistent", "start")), CancellationToken.None).AsTask()).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("nonexistent", "start")), CancellationToken.None).DefaultTimeout(); - Assert.Contains("Resource not found", exception.Message); + Assert.True(result.IsError); + Assert.Contains(result.Content, c => c is ModelContextProtocol.Protocol.TextContentBlock t && t.Text.Contains("Resource not found")); } [Fact] @@ -150,5 +150,54 @@ public async Task ExecuteResourceCommandTool_ThrowsException_WhenMissingArgument () => tool.CallToolAsync(CallToolContextTestHelper.Create(partialArgs), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("Missing required arguments", exception2.Message); } + + [Fact] + public async Task ExecuteResourceCommandTool_ReturnsResult_WhenCommandReturnsResultData() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ExecuteResourceCommandResult = new ExecuteResourceCommandResponse + { + Success = true, + Result = "{\"token\": \"abc123\"}", + ResultFormat = "json" + } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("api-service", "generate-token")), CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Equal(2, result.Content.Count); + + var successText = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(successText); + Assert.Contains("successfully", successText.Text); + + var resultText = result.Content[1] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(resultText); + Assert.Contains("abc123", resultText.Text); + } + + [Fact] + public async Task ExecuteResourceCommandTool_NoExtraContent_WhenCommandSucceedsWithoutResult() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ExecuteResourceCommandResult = new ExecuteResourceCommandResponse { Success = true } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("api-service", "start")), CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs index befce116ddf..b9375706c26 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs @@ -146,8 +146,11 @@ public void DisplayError(string errorMessage) DisplayedErrors.Add(errorMessage); } + public Action? DisplayMessageCallback { get; set; } + public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { + DisplayMessageCallback?.Invoke(emoji, message); } public void DisplaySuccess(string message, bool allowMarkup = false) @@ -197,8 +200,11 @@ public void DisplayPlainText(string text) { } + public Action? DisplayRawTextCallback { get; set; } + public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { + DisplayRawTextCallback?.Invoke(text); } public void DisplayMarkdown(string markdown) diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs index 7654c343d14..4c5e223451c 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs @@ -853,7 +853,7 @@ private void SetupConsoleLogsServices(TestDashboardClient? dashboardClient = nul Services.AddSingleton(); Services.AddSingleton(); Services.AddSingleton(dashboardClient ?? new TestDashboardClient()); - Services.AddSingleton(); + Services.AddScoped(); Services.AddSingleton(); } } diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index 9c0cdfe2a7f..c6157e26d7e 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -127,6 +127,15 @@ const ( EndpointPropertyTlsEnabled EndpointProperty = "TlsEnabled" ) +// CommandResultFormat represents CommandResultFormat. +type CommandResultFormat string + +const ( + CommandResultFormatNone CommandResultFormat = "None" + CommandResultFormatText CommandResultFormat = "Text" + CommandResultFormatJson CommandResultFormat = "Json" +) + // UrlDisplayLocation represents UrlDisplayLocation. type UrlDisplayLocation string @@ -235,6 +244,8 @@ type ExecuteCommandResult struct { Success bool `json:"Success,omitempty"` Canceled bool `json:"Canceled,omitempty"` ErrorMessage string `json:"ErrorMessage,omitempty"` + Result string `json:"Result,omitempty"` + ResultFormat CommandResultFormat `json:"ResultFormat,omitempty"` } // ToMap converts the DTO to a map for JSON serialization. @@ -243,6 +254,8 @@ func (d *ExecuteCommandResult) ToMap() map[string]any { "Success": SerializeValue(d.Success), "Canceled": SerializeValue(d.Canceled), "ErrorMessage": SerializeValue(d.ErrorMessage), + "Result": SerializeValue(d.Result), + "ResultFormat": SerializeValue(d.ResultFormat), } } diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index 38850f68c1d..4373c223a01 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -2750,6 +2750,36 @@ public Map toMap() { } } +// ===== CommandResultFormat.java ===== +// CommandResultFormat.java - GENERATED CODE - DO NOT EDIT + +package aspire; + +import java.util.*; +import java.util.function.*; + +/** CommandResultFormat enum. */ +public enum CommandResultFormat implements WireValueEnum { + NONE("None"), + TEXT("Text"), + JSON("Json"); + + private final String value; + + CommandResultFormat(String value) { + this.value = value; + } + + public String getValue() { return value; } + + public static CommandResultFormat fromValue(String value) { + for (CommandResultFormat e : values()) { + if (e.value.equals(value)) return e; + } + throw new IllegalArgumentException("Unknown value: " + value); + } +} + // ===== CompleteStepMarkdownOptions.java ===== // CompleteStepMarkdownOptions.java - GENERATED CODE - DO NOT EDIT @@ -9117,6 +9147,8 @@ public class ExecuteCommandResult { private boolean success; private boolean canceled; private String errorMessage; + private String result; + private CommandResultFormat resultFormat; public boolean getSuccess() { return success; } public void setSuccess(boolean value) { this.success = value; } @@ -9124,12 +9156,18 @@ public class ExecuteCommandResult { public void setCanceled(boolean value) { this.canceled = value; } public String getErrorMessage() { return errorMessage; } public void setErrorMessage(String value) { this.errorMessage = value; } + public String getResult() { return result; } + public void setResult(String value) { this.result = value; } + public CommandResultFormat getResultFormat() { return resultFormat; } + public void setResultFormat(CommandResultFormat value) { this.resultFormat = value; } public Map toMap() { Map map = new HashMap<>(); map.put("Success", AspireClient.serializeValue(success)); map.put("Canceled", AspireClient.serializeValue(canceled)); map.put("ErrorMessage", AspireClient.serializeValue(errorMessage)); + map.put("Result", AspireClient.serializeValue(result)); + map.put("ResultFormat", AspireClient.serializeValue(resultFormat)); return map; } } @@ -20081,6 +20119,7 @@ public WithVolumeOptions isReadOnly(Boolean value) { .modules/CertificateTrustScope.java .modules/CommandLineArgsCallbackContext.java .modules/CommandOptions.java +.modules/CommandResultFormat.java .modules/CompleteStepMarkdownOptions.java .modules/CompleteStepOptions.java .modules/CompleteTaskMarkdownOptions.java diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index ab4ea516e51..3873037f694 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -1491,6 +1491,8 @@ def _validate_dict_types(args: typing.Any, arg_types: typing.Any) -> bool: CertificateTrustScope = typing.Literal["None", "Append", "Override", "System"] +CommandResultFormat = typing.Literal["None", "Text", "Json"] + ContainerLifetime = typing.Literal["Session", "Persistent"] DistributedApplicationOperation = typing.Literal["Run", "Publish"] @@ -1678,6 +1680,8 @@ class ExecuteCommandResult(typing.TypedDict, total=False): Success: bool Canceled: bool ErrorMessage: str + Result: str + ResultFormat: CommandResultFormat class ResourceEventDto(typing.TypedDict, total=False): ResourceName: str diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index 589382ca04e..a4608afd2eb 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -315,6 +315,28 @@ impl std::fmt::Display for EndpointProperty { } } +/// CommandResultFormat +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum CommandResultFormat { + #[default] + #[serde(rename = "None")] + None, + #[serde(rename = "Text")] + Text, + #[serde(rename = "Json")] + Json, +} + +impl std::fmt::Display for CommandResultFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => write!(f, "None"), + Self::Text => write!(f, "Text"), + Self::Json => write!(f, "Json"), + } + } +} + /// UrlDisplayLocation #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum UrlDisplayLocation { @@ -493,6 +515,10 @@ pub struct ExecuteCommandResult { pub canceled: bool, #[serde(rename = "ErrorMessage")] pub error_message: String, + #[serde(rename = "Result")] + pub result: String, + #[serde(rename = "ResultFormat")] + pub result_format: CommandResultFormat, } impl ExecuteCommandResult { @@ -501,6 +527,8 @@ impl ExecuteCommandResult { map.insert("Success".to_string(), serde_json::to_value(&self.success).unwrap_or(Value::Null)); map.insert("Canceled".to_string(), serde_json::to_value(&self.canceled).unwrap_or(Value::Null)); map.insert("ErrorMessage".to_string(), serde_json::to_value(&self.error_message).unwrap_or(Value::Null)); + map.insert("Result".to_string(), serde_json::to_value(&self.result).unwrap_or(Value::Null)); + map.insert("ResultFormat".to_string(), serde_json::to_value(&self.result_format).unwrap_or(Value::Null)); map } } diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index 174077be454..69fa9093d1c 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -250,6 +250,13 @@ export enum CertificateTrustScope { System = "System", } +/** Enum type for CommandResultFormat */ +export enum CommandResultFormat { + None = "None", + Text = "Text", + Json = "Json", +} + /** Enum type for ContainerLifetime */ export enum ContainerLifetime { Session = "Session", @@ -390,6 +397,8 @@ export interface ExecuteCommandResult { success?: boolean; canceled?: boolean; errorMessage?: string; + result?: string; + resultFormat?: CommandResultFormat; } /** DTO interface for ResourceEventDto */ diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs index 971b6566452..1d8353fa114 100644 --- a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs @@ -368,6 +368,96 @@ public async Task ExecuteCommandAsync_LegacyCommandName_ById_FallsBackToCurrentN Assert.True(result.Success); } + [Fact] + public async Task ExecuteCommandAsync_SuccessWithResult_ReturnsResultData() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var custom = builder.AddResource(new CustomResource("myResource")); + custom.WithCommand(name: "generate-token", + displayName: "Generate Token", + executeCommand: _ => Task.FromResult(CommandResults.Success("{\"token\": \"abc123\"}", CommandResultFormat.Json))); + + var app = builder.Build(); + await app.StartAsync(); + + var result = await app.ResourceCommands.ExecuteCommandAsync(custom.Resource, "generate-token"); + + Assert.True(result.Success); + Assert.Equal("{\"token\": \"abc123\"}", result.Result); + Assert.Equal(CommandResultFormat.Json, result.ResultFormat); + } + + [Fact] + public async Task ExecuteCommandAsync_SuccessWithoutResult_ReturnsNoResultData() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var custom = builder.AddResource(new CustomResource("myResource")); + custom.WithCommand(name: "mycommand", + displayName: "My command", + executeCommand: _ => Task.FromResult(CommandResults.Success())); + + var app = builder.Build(); + await app.StartAsync(); + + var result = await app.ResourceCommands.ExecuteCommandAsync(custom.Resource, "mycommand"); + + Assert.True(result.Success); + Assert.Null(result.Result); + Assert.Equal(CommandResultFormat.None, result.ResultFormat); + } + + [Fact] + public async Task ExecuteCommandAsync_HasReplicas_SuccessWithResult_ReturnsFirstResultData() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var callCount = 0; + var custom = builder.AddResource(new CustomResource("myResource")); + custom.WithAnnotation(new DcpInstancesAnnotation([ + new DcpInstance("myResource-abcdwxyz", "abcdwxyz", 0), + new DcpInstance("myResource-efghwxyz", "efghwxyz", 1) + ])); + custom.WithCommand(name: "generate-token", + displayName: "Generate Token", + executeCommand: e => + { + var count = Interlocked.Increment(ref callCount); + return Task.FromResult(CommandResults.Success($"token-{count}", CommandResultFormat.Text)); + }); + + var app = builder.Build(); + await app.StartAsync(); + + var result = await app.ResourceCommands.ExecuteCommandAsync(custom.Resource, "generate-token"); + + Assert.True(result.Success); + Assert.NotNull(result.Result); + Assert.StartsWith("token-", result.Result); + Assert.Equal(CommandResultFormat.Text, result.ResultFormat); + } + + [Fact] + public void CommandResults_SuccessWithResult_ProducesCorrectResult() + { + var result = CommandResults.Success("{\"key\": \"value\"}", CommandResultFormat.Json); + + Assert.True(result.Success); + Assert.Equal("{\"key\": \"value\"}", result.Result); + Assert.Equal(CommandResultFormat.Json, result.ResultFormat); + } + + [Fact] + public void CommandResults_SuccessWithTextResult_DefaultsToText() + { + var result = CommandResults.Success("hello world"); + + Assert.True(result.Success); + Assert.Equal("hello world", result.Result); + Assert.Equal(CommandResultFormat.Text, result.ResultFormat); + } + private sealed class CustomResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithWaitSupport {