diff --git a/playground/python/Python.AppHost/Program.cs b/playground/python/Python.AppHost/Program.cs
index 3f0b16ee901..d301b3e0b39 100644
--- a/playground/python/Python.AppHost/Program.cs
+++ b/playground/python/Python.AppHost/Program.cs
@@ -3,22 +3,22 @@
var builder = DistributedApplication.CreateBuilder(args);
-builder.AddPythonApp("script-only", "../script_only", "main.py");
+builder.AddPythonApp("scriptonly", "../script_only", "main.py");
builder.AddPythonApp("instrumented-script", "../instrumented_script", "main.py");
-builder.AddPythonModule("fastapi-app", "../module_only", "uvicorn")
+builder.AddPythonModule("fastapiapp", "../module_only", "uvicorn")
.WithArgs("api:app", "--reload", "--host=0.0.0.0", "--port=8000")
.WithHttpEndpoint(targetPort: 8000)
.WithUv();
// Run the same app on another port using uvicorn directly
-builder.AddPythonExecutable("fastapi-uvicorn-app", "../module_only", "uvicorn")
+builder.AddPythonExecutable("fastapiuvicornapp", "../module_only", "uvicorn")
.WithDebugging()
.WithArgs("api:app", "--reload", "--host=0.0.0.0", "--port=8001")
.WithHttpEndpoint(targetPort: 8001);
// Flask app using Flask module directly
-builder.AddPythonModule("flask-app", "../flask_app", "flask")
+builder.AddPythonModule("flaskapp", "../flask_app", "flask")
.WithEnvironment("FLASK_APP", "app:create_app")
.WithArgs(c =>
{
@@ -30,10 +30,14 @@
.WithUv();
// Uvicorn app using the AddUvicornApp method
-builder.AddUvicornApp("uvicorn-app", "../uvicorn_app", "app:app")
+var uvicornApp = builder.AddUvicornApp("uvicornapp", "../uvicorn_app", "app:app")
.WithUv()
.WithExternalHttpEndpoints();
+builder.AddPytest("uvicorn-tests", "../uvicorn_app")
+ .WithReference(uvicornApp).WaitFor(uvicornApp)
+ .WithUv();
+
#if !SKIP_DASHBOARD_REFERENCE
// This project is only added in playground projects to support development/debugging
// of the dashboard. It is not required in end developer code. Comment out this code
diff --git a/playground/python/uvicorn_app/app.py b/playground/python/uvicorn_app/app.py
index 10d6f8372e9..6927085ef33 100644
--- a/playground/python/uvicorn_app/app.py
+++ b/playground/python/uvicorn_app/app.py
@@ -27,6 +27,7 @@ async def root():
"""Root endpoint."""
return "API service is running. Navigate to /weatherforecast to see sample data."
+@app.get("/weatherforecast")
async def weather_forecast():
"""Weather forecast endpoint."""
# Generate fresh data if not in cache or cache unavailable.
diff --git a/playground/python/uvicorn_app/pyproject.toml b/playground/python/uvicorn_app/pyproject.toml
index 759b3cd4608..f56a8ff7f5e 100644
--- a/playground/python/uvicorn_app/pyproject.toml
+++ b/playground/python/uvicorn_app/pyproject.toml
@@ -9,3 +9,9 @@ dependencies = [
"opentelemetry-exporter-otlp-proto-grpc>=1.38.0",
"opentelemetry-instrumentation-fastapi>=0.59b0",
]
+
+[dependency-groups]
+dev = [
+ "pytest>=8.0.0",
+ "httpx>=0.27.0",
+]
diff --git a/playground/python/uvicorn_app/tests/__init__.py b/playground/python/uvicorn_app/tests/__init__.py
new file mode 100644
index 00000000000..888aa66c1a7
--- /dev/null
+++ b/playground/python/uvicorn_app/tests/__init__.py
@@ -0,0 +1,2 @@
+# Licensed to the .NET Foundation under one or more agreements.
+# The .NET Foundation licenses this file to you under the MIT license.
diff --git a/playground/python/uvicorn_app/tests/test_restapi.py b/playground/python/uvicorn_app/tests/test_restapi.py
new file mode 100644
index 00000000000..561d2b78c84
--- /dev/null
+++ b/playground/python/uvicorn_app/tests/test_restapi.py
@@ -0,0 +1,46 @@
+# Licensed to the .NET Foundation under one or more agreements.
+# The .NET Foundation licenses this file to you under the MIT license.
+
+"""Integration tests for the uvicorn app endpoints."""
+
+import os
+
+import httpx
+import pytest
+
+
+@pytest.fixture
+def base_url():
+ """Get the base URL from environment variable."""
+ url = os.environ.get("UVICORNAPP_HTTP")
+ if not url:
+ pytest.skip("UVICORNAPP_HTTP environment variable not set")
+ return url.rstrip("/")
+
+
+def test_root_endpoint(base_url):
+ """Test the root endpoint returns expected message."""
+ response = httpx.get(f"{base_url}/")
+ assert response.status_code == 200
+ assert "API service is running" in response.text
+
+
+def test_weatherforecast_endpoint(base_url):
+ """Test the weatherforecast endpoint returns valid forecast data."""
+ response = httpx.get(f"{base_url}/weatherforecast")
+ assert response.status_code == 200
+ data = response.json()
+ assert isinstance(data, list)
+ assert len(data) == 5
+ for item in data:
+ assert "date" in item
+ assert "temperatureC" in item
+ assert "temperatureF" in item
+ assert "summary" in item
+
+
+def test_health_endpoint(base_url):
+ """Test the health check endpoint returns healthy status."""
+ response = httpx.get(f"{base_url}/health")
+ assert response.status_code == 200
+ assert response.text == "Healthy"
diff --git a/playground/python/uvicorn_app/uv.lock b/playground/python/uvicorn_app/uv.lock
index 6aa77515380..c8294ff18c9 100644
--- a/playground/python/uvicorn_app/uv.lock
+++ b/playground/python/uvicorn_app/uv.lock
@@ -168,6 +168,12 @@ dependencies = [
{ name = "opentelemetry-instrumentation-fastapi" },
]
+[package.dev-dependencies]
+dev = [
+ { name = "httpx" },
+ { name = "pytest" },
+]
+
[package.metadata]
requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.119.0" },
@@ -176,6 +182,12 @@ requires-dist = [
{ name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.59b0" },
]
+[package.metadata.requires-dev]
+dev = [
+ { name = "httpx", specifier = ">=0.27.0" },
+ { name = "pytest", specifier = ">=8.0.0" },
+]
+
[[package]]
name = "googleapis-common-protos"
version = "1.71.0"
@@ -299,6 +311,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" },
]
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -545,6 +566,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
[[package]]
name = "protobuf"
version = "6.33.0"
@@ -638,6 +668,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
+[[package]]
+name = "pytest"
+version = "9.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" },
+]
+
[[package]]
name = "python-dotenv"
version = "1.2.1"
diff --git a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannel.cs
new file mode 100644
index 00000000000..e2309079ff8
--- /dev/null
+++ b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannel.cs
@@ -0,0 +1,47 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using StreamJsonRpc;
+
+namespace Aspire.Cli.Backchannel;
+
+///
+/// Encapsulates communication with an AppHost via the auxiliary backchannel.
+///
+internal sealed class AuxiliaryBackchannel : IAuxiliaryBackchannel
+{
+ private readonly JsonRpc _rpc;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The JSON-RPC connection to the AppHost.
+ public AuxiliaryBackchannel(JsonRpc rpc)
+ {
+ _rpc = rpc ?? throw new ArgumentNullException(nameof(rpc));
+ }
+
+ ///
+ public Task GetAppHostInformationAsync(CancellationToken cancellationToken = default)
+ {
+ return _rpc.InvokeWithCancellationAsync("GetAppHostInformationAsync", cancellationToken: cancellationToken);
+ }
+
+ ///
+ public Task GetDashboardMcpConnectionInfoAsync(CancellationToken cancellationToken = default)
+ {
+ return _rpc.InvokeWithCancellationAsync("GetDashboardMcpConnectionInfoAsync", cancellationToken: cancellationToken);
+ }
+
+ ///
+ public Task GetTestResultsAsync(CancellationToken cancellationToken = default)
+ {
+ return _rpc.InvokeWithCancellationAsync("GetTestResultsAsync", cancellationToken: cancellationToken);
+ }
+
+ ///
+ public Task StopAppHostAsync(CancellationToken cancellationToken = default)
+ {
+ return _rpc.InvokeWithCancellationAsync("StopAppHostAsync", cancellationToken: cancellationToken);
+ }
+}
diff --git a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs
index 37b56596bed..bfd8631d268 100644
--- a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs
+++ b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs
@@ -185,16 +185,19 @@ private async Task TryConnectToSocketAsync(string socketPath, CancellationToken
var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter()));
rpc.StartListening();
+ // Create the auxiliary backchannel wrapper
+ var backchannel = new AuxiliaryBackchannel(rpc);
+
// Get the AppHost information
- var appHostInfo = await rpc.InvokeAsync("GetAppHostInformationAsync").ConfigureAwait(false);
+ var appHostInfo = await backchannel.GetAppHostInformationAsync(cancellationToken).ConfigureAwait(false);
// Get the MCP connection info
- var mcpInfo = await rpc.InvokeAsync("GetDashboardMcpConnectionInfoAsync").ConfigureAwait(false);
+ var mcpInfo = await backchannel.GetDashboardMcpConnectionInfoAsync(cancellationToken).ConfigureAwait(false);
// Determine if this AppHost is in scope of the MCP server's working directory
var isInScope = IsAppHostInScope(appHostInfo?.AppHostPath);
- var connection = new AppHostConnection(hash, socketPath, rpc, mcpInfo, appHostInfo, isInScope);
+ var connection = new AppHostConnection(hash, socketPath, rpc, backchannel, mcpInfo, appHostInfo, isInScope);
// Set up disconnect handler
rpc.Disconnected += (sender, args) =>
@@ -320,11 +323,12 @@ internal sealed class AppHostConnection
///
/// Initializes a new instance of the class.
///
- public AppHostConnection(string hash, string socketPath, JsonRpc rpc, DashboardMcpConnectionInfo? mcpInfo, AppHostInformation? appHostInfo, bool isInScope)
+ public AppHostConnection(string hash, string socketPath, JsonRpc rpc, IAuxiliaryBackchannel backchannel, DashboardMcpConnectionInfo? mcpInfo, AppHostInformation? appHostInfo, bool isInScope)
{
Hash = hash;
SocketPath = socketPath;
Rpc = rpc;
+ Backchannel = backchannel;
McpInfo = mcpInfo;
AppHostInfo = appHostInfo;
IsInScope = isInScope;
@@ -346,6 +350,11 @@ public AppHostConnection(string hash, string socketPath, JsonRpc rpc, DashboardM
///
public JsonRpc Rpc { get; }
+ ///
+ /// Gets the auxiliary backchannel for communicating with the AppHost.
+ ///
+ public IAuxiliaryBackchannel Backchannel { get; }
+
///
/// Gets the MCP connection information for the Dashboard.
///
diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs
index 7412289f3e0..2031ecf04eb 100644
--- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs
+++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs
@@ -35,6 +35,9 @@ namespace Aspire.Cli.Backchannel;
[JsonSerializable(typeof(AppHostProjectSearchResultPoco))]
[JsonSerializable(typeof(DashboardMcpConnectionInfo))]
[JsonSerializable(typeof(AppHostInformation))]
+[JsonSerializable(typeof(TestResults))]
+[JsonSerializable(typeof(TestResourceResult))]
+[JsonSerializable(typeof(TestResultFileInfo))]
internal partial class BackchannelJsonSerializerContext : JsonSerializerContext
{
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Using the Json source generator.")]
diff --git a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannel.cs
new file mode 100644
index 00000000000..bb3eb7cf7cf
--- /dev/null
+++ b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannel.cs
@@ -0,0 +1,38 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Cli.Backchannel;
+
+///
+/// Interface for communicating with an AppHost via the auxiliary backchannel.
+///
+internal interface IAuxiliaryBackchannel
+{
+ ///
+ /// Gets information about the AppHost.
+ ///
+ /// A cancellation token.
+ /// The AppHost information including the fully qualified path and process ID.
+ Task GetAppHostInformationAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the Dashboard MCP connection information including endpoint URL and API token.
+ ///
+ /// A cancellation token.
+ /// The MCP connection information, or null if the dashboard is not part of the application model.
+ Task GetDashboardMcpConnectionInfoAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the test results by waiting for all test resources to complete.
+ ///
+ /// A cancellation token.
+ /// The test results.
+ Task GetTestResultsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Initiates an orderly shutdown of the AppHost.
+ ///
+ /// A cancellation token.
+ /// A task that completes when the shutdown request has been sent.
+ Task StopAppHostAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs
index 6a85abe96f2..178a09acd82 100644
--- a/src/Aspire.Cli/Commands/RootCommand.cs
+++ b/src/Aspire.Cli/Commands/RootCommand.cs
@@ -32,6 +32,7 @@ public RootCommand(
ExecCommand execCommand,
UpdateCommand updateCommand,
McpCommand mcpCommand,
+ TestCommand testCommand,
ExtensionInternalCommand extensionInternalCommand,
IFeatures featureFlags,
IInteractionService interactionService)
@@ -49,6 +50,7 @@ public RootCommand(
ArgumentNullException.ThrowIfNull(updateCommand);
ArgumentNullException.ThrowIfNull(execCommand);
ArgumentNullException.ThrowIfNull(mcpCommand);
+ ArgumentNullException.ThrowIfNull(testCommand);
ArgumentNullException.ThrowIfNull(extensionInternalCommand);
ArgumentNullException.ThrowIfNull(featureFlags);
ArgumentNullException.ThrowIfNull(interactionService);
@@ -122,5 +124,10 @@ public RootCommand(
Subcommands.Add(execCommand);
}
+ if (featureFlags.IsFeatureEnabled(KnownFeatures.TestCommandEnabled, false))
+ {
+ Subcommands.Add(testCommand);
+ }
+
}
}
diff --git a/src/Aspire.Cli/Commands/TestCommand.cs b/src/Aspire.Cli/Commands/TestCommand.cs
new file mode 100644
index 00000000000..7eae1b3a87d
--- /dev/null
+++ b/src/Aspire.Cli/Commands/TestCommand.cs
@@ -0,0 +1,209 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using System.Globalization;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Text;
+using Aspire.Cli.Backchannel;
+using Aspire.Cli.Configuration;
+using Aspire.Cli.DotNet;
+using Aspire.Cli.Interaction;
+using Aspire.Cli.Projects;
+using Aspire.Cli.Resources;
+using Aspire.Cli.Telemetry;
+using Aspire.Cli.Utils;
+using Spectre.Console;
+using StreamJsonRpc;
+
+namespace Aspire.Cli.Commands;
+
+internal class TestCommand : BaseCommand
+{
+ private readonly IDotNetCliRunner _runner;
+ private readonly IProjectLocator _projectLocator;
+ private readonly AspireCliTelemetry _telemetry;
+
+ public TestCommand(
+ IDotNetCliRunner runner,
+ IProjectLocator projectLocator,
+ IInteractionService interactionService,
+ AspireCliTelemetry telemetry,
+ IFeatures features,
+ ICliUpdateNotifier updateNotifier,
+ CliExecutionContext executionContext)
+ : base("test", TestCommandStrings.Description, features, updateNotifier, executionContext, interactionService)
+ {
+ ArgumentNullException.ThrowIfNull(runner);
+ ArgumentNullException.ThrowIfNull(projectLocator);
+ ArgumentNullException.ThrowIfNull(interactionService);
+ ArgumentNullException.ThrowIfNull(telemetry);
+ ArgumentNullException.ThrowIfNull(features);
+
+ _runner = runner;
+ _projectLocator = projectLocator;
+ _telemetry = telemetry;
+
+ var projectOption = new Option("--project");
+ projectOption.Description = TestCommandStrings.ProjectArgumentDescription;
+ Options.Add(projectOption);
+ }
+
+ protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
+ {
+ var passedAppHostProjectFile = parseResult.GetValue("--project");
+
+ var runOutputCollector = new OutputCollector();
+
+ try
+ {
+ using var activity = _telemetry.ActivitySource.StartActivity(this.Name);
+
+ var effectiveAppHostFile = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, createSettingsFile: false, cancellationToken);
+
+ if (effectiveAppHostFile is null)
+ {
+ return ExitCodeConstants.FailedToFindProject;
+ }
+
+ var runOptions = new DotNetCliRunnerInvocationOptions
+ {
+ StandardOutputCallback = runOutputCollector.AppendOutput,
+ StandardErrorCallback = runOutputCollector.AppendError,
+ };
+
+ var backchannelCompletitionSource = new TaskCompletionSource();
+
+ var env = new Dictionary();
+
+ var pendingRun = _runner.RunAsync(
+ effectiveAppHostFile,
+ watch: false,
+ noBuild: false,
+ Array.Empty(),
+ env,
+ backchannelCompletitionSource,
+ runOptions,
+ cancellationToken);
+
+ // Wait for the backchannel to be established.
+ var backchannel = await InteractionService.ShowStatusAsync(TestCommandStrings.ConnectingToAppHost, async () => { return await backchannelCompletitionSource.Task.WaitAsync(cancellationToken); });
+
+ InteractionService.DisplaySuccess(TestCommandStrings.AppHostStarted);
+
+ // Connect to the auxiliary backchannel
+ var auxiliaryBackchannel = await ConnectToAuxiliaryBackchannelAsync(effectiveAppHostFile.FullName, cancellationToken);
+
+ // Wait for test results
+ InteractionService.DisplayEmptyLine();
+ var testResults = await InteractionService.ShowStatusAsync("Running tests...", async () =>
+ {
+ return await auxiliaryBackchannel.GetTestResultsAsync(cancellationToken);
+ });
+
+ // Display test results
+ InteractionService.DisplayEmptyLine();
+ if (testResults?.Success == true)
+ {
+ InteractionService.DisplaySuccess(testResults.Message);
+ }
+ else
+ {
+ InteractionService.DisplayError(testResults?.Message ?? "Test execution failed.");
+ }
+
+ // Display links to test result files
+ if (testResults?.TestResourceResults is not null)
+ {
+ foreach (var resourceResult in testResults.TestResourceResults)
+ {
+ if (resourceResult.ResultFiles is { Length: > 0 })
+ {
+ foreach (var resultFile in resourceResult.ResultFiles)
+ {
+ var filePath = resultFile.FilePath;
+ var fileLink = $"[link=file://{filePath.EscapeMarkup()}]{filePath.EscapeMarkup()}[/]";
+ InteractionService.DisplayMessage("page_facing_up", $"Test results ({resourceResult.ResourceName}): {fileLink}");
+ }
+ }
+ }
+ }
+
+ // Stop the AppHost
+ await auxiliaryBackchannel.StopAppHostAsync(cancellationToken);
+
+ // Wait for the apphost to exit
+ return await pendingRun;
+ }
+ catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken)
+ {
+ InteractionService.DisplayCancellationMessage();
+ return ExitCodeConstants.Success;
+ }
+ catch (ProjectLocatorException ex)
+ {
+ return HandleProjectLocatorException(ex, InteractionService);
+ }
+ catch (FailedToConnectBackchannelConnection ex)
+ {
+ InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message.EscapeMarkup()));
+ InteractionService.DisplayLines(runOutputCollector.GetLines());
+ return ExitCodeConstants.FailedToDotnetRunAppHost;
+ }
+ catch (Exception ex)
+ {
+ InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message.EscapeMarkup()));
+ InteractionService.DisplayLines(runOutputCollector.GetLines());
+ return ExitCodeConstants.FailedToDotnetRunAppHost;
+ }
+ }
+
+ private static async Task ConnectToAuxiliaryBackchannelAsync(string appHostPath, CancellationToken cancellationToken)
+ {
+ var socketPath = GetAuxiliaryBackchannelSocketPath(appHostPath);
+
+ // Wait for the socket to be created (with timeout)
+ var timeout = TimeSpan.FromSeconds(30);
+ var startTime = DateTime.UtcNow;
+
+ while (!File.Exists(socketPath))
+ {
+ if (DateTime.UtcNow - startTime > timeout)
+ {
+ throw new InvalidOperationException($"Auxiliary backchannel socket not found at {socketPath} after waiting {timeout.TotalSeconds} seconds");
+ }
+
+ await Task.Delay(100, cancellationToken);
+ }
+
+ // Give the socket a moment to be ready
+ await Task.Delay(100, cancellationToken);
+
+ // Connect to the Unix socket
+ var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+ var endpoint = new UnixDomainSocketEndPoint(socketPath);
+
+ await socket.ConnectAsync(endpoint, cancellationToken);
+
+ // Create JSON-RPC connection
+ var stream = new NetworkStream(socket, ownsSocket: true);
+ var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter()));
+ rpc.StartListening();
+
+ return new AuxiliaryBackchannel(rpc);
+ }
+
+ private static string GetAuxiliaryBackchannelSocketPath(string appHostPath)
+ {
+ var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var backchannelsDir = Path.Combine(homeDirectory, ".aspire", "cli", "backchannels");
+
+ // Compute hash from the AppHost path for consistency
+ var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(appHostPath));
+ // Use first 16 characters to keep socket path length reasonable
+ var hash = Convert.ToHexString(hashBytes)[..16].ToLowerInvariant();
+
+ return Path.Combine(backchannelsDir, $"aux.sock.{hash}");
+ }
+}
diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs
index 89c5acbe2fe..7f598f4fc9a 100644
--- a/src/Aspire.Cli/KnownFeatures.cs
+++ b/src/Aspire.Cli/KnownFeatures.cs
@@ -18,4 +18,5 @@ internal static class KnownFeatures
public static string DefaultWatchEnabled => "defaultWatchEnabled";
public static string ShowAllTemplates => "showAllTemplates";
public static string DotNetSdkInstallationEnabled => "dotnetSdkInstallationEnabled";
+ public static string TestCommandEnabled => "testCommandEnabled";
}
diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs
index bd763009c46..0070810fb9f 100644
--- a/src/Aspire.Cli/Program.cs
+++ b/src/Aspire.Cli/Program.cs
@@ -207,6 +207,7 @@ private static async Task BuildApplicationAsync(string[] args)
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
+ builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
diff --git a/src/Aspire.Cli/Resources/TestCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/TestCommandStrings.Designer.cs
new file mode 100644
index 00000000000..6f7e2877c6b
--- /dev/null
+++ b/src/Aspire.Cli/Resources/TestCommandStrings.Designer.cs
@@ -0,0 +1,99 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace Aspire.Cli.Resources {
+ using System;
+
+
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ ///
+ // This class was auto-generated by the StronglyTypedResourceBuilder
+ // class via a tool like ResGen or Visual Studio.
+ // To add or remove a member, edit your .ResX file then rerun ResGen
+ // with the /str option, or rebuild your VS project.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ internal class TestCommandStrings {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal TestCommandStrings() {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Aspire.Cli.Resources.TestCommandStrings", typeof(TestCommandStrings).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to AppHost started. Waiting for completion....
+ ///
+ internal static string AppHostStarted {
+ get {
+ return ResourceManager.GetString("AppHostStarted", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Connecting to AppHost....
+ ///
+ internal static string ConnectingToAppHost {
+ get {
+ return ResourceManager.GetString("ConnectingToAppHost", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview).
+ ///
+ internal static string Description {
+ get {
+ return ResourceManager.GetString("Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory..
+ ///
+ internal static string ProjectArgumentDescription {
+ get {
+ return ResourceManager.GetString("ProjectArgumentDescription", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/src/Aspire.Cli/Resources/TestCommandStrings.resx b/src/Aspire.Cli/Resources/TestCommandStrings.resx
new file mode 100644
index 00000000000..1f1faa73ea2
--- /dev/null
+++ b/src/Aspire.Cli/Resources/TestCommandStrings.resx
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+ Connecting to AppHost...
+
+
+ AppHost started. Waiting for completion...
+
+
diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.cs.xlf
new file mode 100644
index 00000000000..f712073d96c
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.cs.xlf
@@ -0,0 +1,27 @@
+
+
+
+
+
+ AppHost started. Waiting for completion...
+ AppHost started. Waiting for completion...
+
+
+
+ Connecting to AppHost...
+ Connecting to AppHost...
+
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.de.xlf
new file mode 100644
index 00000000000..ca702f76d93
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.de.xlf
@@ -0,0 +1,27 @@
+
+
+
+
+
+ AppHost started. Waiting for completion...
+ AppHost started. Waiting for completion...
+
+
+
+ Connecting to AppHost...
+ Connecting to AppHost...
+
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.es.xlf
new file mode 100644
index 00000000000..97275e4cc04
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.es.xlf
@@ -0,0 +1,27 @@
+
+
+
+
+
+ AppHost started. Waiting for completion...
+ AppHost started. Waiting for completion...
+
+
+
+ Connecting to AppHost...
+ Connecting to AppHost...
+
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.fr.xlf
new file mode 100644
index 00000000000..6df8e89e985
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.fr.xlf
@@ -0,0 +1,27 @@
+
+
+
+
+
+ AppHost started. Waiting for completion...
+ AppHost started. Waiting for completion...
+
+
+
+ Connecting to AppHost...
+ Connecting to AppHost...
+
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.it.xlf
new file mode 100644
index 00000000000..fa181be6cb7
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.it.xlf
@@ -0,0 +1,27 @@
+
+
+
+
+
+ AppHost started. Waiting for completion...
+ AppHost started. Waiting for completion...
+
+
+
+ Connecting to AppHost...
+ Connecting to AppHost...
+
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ja.xlf
new file mode 100644
index 00000000000..f55d8a36d1b
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ja.xlf
@@ -0,0 +1,27 @@
+
+
+
+
+
+ AppHost started. Waiting for completion...
+ AppHost started. Waiting for completion...
+
+
+
+ Connecting to AppHost...
+ Connecting to AppHost...
+
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ko.xlf
new file mode 100644
index 00000000000..7ef7a8bdab4
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ko.xlf
@@ -0,0 +1,27 @@
+
+
+
+
+
+ AppHost started. Waiting for completion...
+ AppHost started. Waiting for completion...
+
+
+
+ Connecting to AppHost...
+ Connecting to AppHost...
+
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.pl.xlf
new file mode 100644
index 00000000000..6cbf3d0bed6
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.pl.xlf
@@ -0,0 +1,27 @@
+
+
+
+
+
+ AppHost started. Waiting for completion...
+ AppHost started. Waiting for completion...
+
+
+
+ Connecting to AppHost...
+ Connecting to AppHost...
+
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.pt-BR.xlf
new file mode 100644
index 00000000000..e483c708e7e
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.pt-BR.xlf
@@ -0,0 +1,27 @@
+
+
+
+
+
+ AppHost started. Waiting for completion...
+ AppHost started. Waiting for completion...
+
+
+
+ Connecting to AppHost...
+ Connecting to AppHost...
+
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ru.xlf
new file mode 100644
index 00000000000..6ee15553192
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.ru.xlf
@@ -0,0 +1,27 @@
+
+
+
+
+
+ AppHost started. Waiting for completion...
+ AppHost started. Waiting for completion...
+
+
+
+ Connecting to AppHost...
+ Connecting to AppHost...
+
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.tr.xlf
new file mode 100644
index 00000000000..fa12dde7b49
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.tr.xlf
@@ -0,0 +1,27 @@
+
+
+
+
+
+ AppHost started. Waiting for completion...
+ AppHost started. Waiting for completion...
+
+
+
+ Connecting to AppHost...
+ Connecting to AppHost...
+
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.zh-Hans.xlf
new file mode 100644
index 00000000000..881b9667483
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.zh-Hans.xlf
@@ -0,0 +1,27 @@
+
+
+
+
+
+ AppHost started. Waiting for completion...
+ AppHost started. Waiting for completion...
+
+
+
+ Connecting to AppHost...
+ Connecting to AppHost...
+
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/TestCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.zh-Hant.xlf
new file mode 100644
index 00000000000..86f406ed57c
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/TestCommandStrings.zh-Hant.xlf
@@ -0,0 +1,27 @@
+
+
+
+
+
+ AppHost started. Waiting for completion...
+ AppHost started. Waiting for completion...
+
+
+
+ Connecting to AppHost...
+ Connecting to AppHost...
+
+
+
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+ Run test resources to completion and then shutdown. Launches the AppHost, executes specified test resources, and exits after tests complete. (Preview)
+
+
+
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+ The path to the Aspire AppHost project file. If not specified, searches for a project file in the current directory.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
index 66900ffafd5..07a24c5e40f 100644
--- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
@@ -140,6 +140,72 @@ public static IResourceBuilder AddPythonExecutable(
this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string executableName)
=> AddPythonAppCore(builder, name, appDirectory, EntrypointType.Executable, executableName, DefaultVirtualEnvFolder);
+ ///
+ /// Adds a pytest test runner to the application model.
+ ///
+ /// The to add the resource to.
+ /// The name of the resource.
+ /// The path to the directory containing the Python test files.
+ /// A reference to the .
+ ///
+ ///
+ /// This method runs pytest from the virtual environment using the pytest executable.
+ /// The resource is automatically marked with a to indicate
+ /// it's a test resource that runs to completion.
+ /// By default, the virtual environment folder is expected to be named .venv and located in the app directory.
+ /// Use to specify a different virtual environment path.
+ /// Use WithArgs to pass arguments to pytest.
+ ///
+ ///
+ ///
+ /// Add a pytest test runner to the application model:
+ ///
+ /// var builder = DistributedApplication.CreateBuilder(args);
+ ///
+ /// builder.AddPytest("api-tests", "../api")
+ /// .WithArgs("-v", "tests/");
+ ///
+ /// builder.Build().Run();
+ ///
+ ///
+ public static IResourceBuilder AddPytest(
+ this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory)
+ {
+ var resource = AddPythonExecutable(builder, name, appDirectory, "pytest");
+ resource.WithAnnotation(new TestResourceAnnotation());
+
+ // Generate a unique file path for pytest report-log output
+ var junitxmlResultFileName = $"{Guid.NewGuid():N}.xml";
+ var junitxmlFilePath = Path.Combine(Path.GetTempPath(), "aspire-test-results", junitxmlResultFileName);
+
+ // Ensure the directory exists
+ Directory.CreateDirectory(Path.GetDirectoryName(junitxmlFilePath)!);
+ // Add the report-log argument to pytest
+ resource.WithArgs(context =>
+ {
+ context.Args.Add($"--junitxml={junitxmlFilePath}");
+ });
+
+ // Add the callback annotation for collecting test results
+ resource.WithAnnotation(new TestResultsCallbackAnnotation(async context =>
+ {
+ // Wait for the test resource to reach a terminal state before collecting results
+ var resourceNotificationService = context.ServiceProvider.GetRequiredService();
+ await resourceNotificationService.WaitForResourceAsync(
+ name,
+ targetStates: KnownResourceStates.TerminalStates,
+ context.CancellationToken).ConfigureAwait(false);
+
+ var junitxmlFile = new FileInfo(junitxmlFilePath);
+ if (junitxmlFile.Exists)
+ {
+ context.AddResultsFile(junitxmlFile, TestResultFormat.PytestReportLog);
+ }
+ }));
+
+ return resource;
+ }
+
///
/// Adds a python application with a virtual environment to the application model.
///
diff --git a/src/Aspire.Hosting/ApplicationModel/TestResourceAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/TestResourceAnnotation.cs
new file mode 100644
index 00000000000..18b90f1819e
--- /dev/null
+++ b/src/Aspire.Hosting/ApplicationModel/TestResourceAnnotation.cs
@@ -0,0 +1,12 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Marker annotation to indicate that a resource is a test resource.
+/// Test resources are expected to run to completion and then exit.
+///
+public sealed class TestResourceAnnotation : IResourceAnnotation
+{
+}
diff --git a/src/Aspire.Hosting/ApplicationModel/TestResultFormat.cs b/src/Aspire.Hosting/ApplicationModel/TestResultFormat.cs
new file mode 100644
index 00000000000..82a718846e0
--- /dev/null
+++ b/src/Aspire.Hosting/ApplicationModel/TestResultFormat.cs
@@ -0,0 +1,15 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Specifies the format of test result files.
+///
+public enum TestResultFormat
+{
+ ///
+ /// The pytest report-log format (JSON lines format with one JSON object per line).
+ ///
+ PytestReportLog
+}
diff --git a/src/Aspire.Hosting/ApplicationModel/TestResultsCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/TestResultsCallbackAnnotation.cs
new file mode 100644
index 00000000000..e6e2541fad9
--- /dev/null
+++ b/src/Aspire.Hosting/ApplicationModel/TestResultsCallbackAnnotation.cs
@@ -0,0 +1,26 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents an annotation that provides a callback to collect test result information from a test resource.
+///
+public sealed class TestResultsCallbackAnnotation : IResourceAnnotation
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The callback to invoke to collect test results.
+ public TestResultsCallbackAnnotation(Func callback)
+ {
+ ArgumentNullException.ThrowIfNull(callback);
+
+ Callback = callback;
+ }
+
+ ///
+ /// Gets the callback to invoke to collect test results.
+ ///
+ public Func Callback { get; }
+}
diff --git a/src/Aspire.Hosting/ApplicationModel/TestResultsCallbackContext.cs b/src/Aspire.Hosting/ApplicationModel/TestResultsCallbackContext.cs
new file mode 100644
index 00000000000..bfd23c78ccd
--- /dev/null
+++ b/src/Aspire.Hosting/ApplicationModel/TestResultsCallbackContext.cs
@@ -0,0 +1,59 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a callback context for collecting test result files from a test resource.
+///
+public sealed class TestResultsCallbackContext
+{
+ private readonly List _resultFiles = [];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The service provider.
+ /// A cancellation token.
+ public TestResultsCallbackContext(IServiceProvider serviceProvider, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(serviceProvider);
+
+ ServiceProvider = serviceProvider;
+ CancellationToken = cancellationToken;
+ }
+
+ ///
+ /// Gets the service provider.
+ ///
+ public IServiceProvider ServiceProvider { get; }
+
+ ///
+ /// Gets the cancellation token.
+ ///
+ public CancellationToken CancellationToken { get; }
+
+ ///
+ /// Gets the collection of test result files that have been added.
+ ///
+ public IReadOnlyList ResultFiles => _resultFiles;
+
+ ///
+ /// Adds a test result file to the context.
+ ///
+ /// The file containing test results.
+ /// The format of the test results.
+ public void AddResultsFile(FileInfo file, TestResultFormat format)
+ {
+ ArgumentNullException.ThrowIfNull(file);
+
+ _resultFiles.Add(new TestResultFile(file, format));
+ }
+}
+
+///
+/// Represents a test result file with its format.
+///
+/// The file containing test results.
+/// The format of the test results.
+public readonly record struct TestResultFile(FileInfo File, TestResultFormat Format);
diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs
index 3526676d504..ea7706ef693 100644
--- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs
+++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs
@@ -5,6 +5,7 @@
using Aspire.Hosting.Dashboard;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -15,7 +16,8 @@ namespace Aspire.Hosting.Backchannel;
///
internal sealed class AuxiliaryBackchannelRpcTarget(
ILogger logger,
- IServiceProvider serviceProvider)
+ IServiceProvider serviceProvider,
+ IHostApplicationLifetime hostApplicationLifetime)
{
private const string McpEndpointName = "mcp";
@@ -126,4 +128,134 @@ public Task GetAppHostInformationAsync(CancellationToken can
ApiToken = mcpApiKey
};
}
+
+ ///
+ /// Gets the test results by waiting for all test resources to complete.
+ ///
+ /// A cancellation token.
+ /// A task that completes when all test resources have reached a completed state.
+ public async Task GetTestResultsAsync(CancellationToken cancellationToken = default)
+ {
+ await Task.Delay(15000, cancellationToken).ConfigureAwait(false);
+
+ var appModel = serviceProvider.GetService();
+ if (appModel is null)
+ {
+ logger.LogWarning("Application model not found.");
+ return new TestResults { Success = false, Message = "Application model not found." };
+ }
+
+ var resourceNotificationService = serviceProvider.GetService();
+ if (resourceNotificationService is null)
+ {
+ logger.LogWarning("ResourceNotificationService not found.");
+ return new TestResults { Success = false, Message = "ResourceNotificationService not found." };
+ }
+
+ // Find all resources with TestResourceAnnotation
+ var testResources = appModel.Resources
+ .Where(r => r.Annotations.OfType().Any())
+ .ToList();
+
+ if (testResources.Count == 0)
+ {
+ logger.LogInformation("No test resources found in the application model.");
+ return new TestResults { Success = true, Message = "No test resources found." };
+ }
+
+ logger.LogInformation("Waiting for {Count} test resource(s) to complete", testResources.Count);
+
+ // Wait for all test resources to reach a completed state and collect results
+ var waitTasks = testResources.Select(async resource =>
+ {
+ try
+ {
+ await resourceNotificationService.WaitForResourceAsync(
+ resource.Name,
+ KnownResourceStates.Finished,
+ cancellationToken).ConfigureAwait(false);
+
+ logger.LogInformation("Test resource '{ResourceName}' completed", resource.Name);
+
+ // Invoke the test results callback if present
+ var callbackAnnotation = resource.Annotations.OfType().FirstOrDefault();
+ TestResultFileInfo[]? resultFiles = null;
+
+ if (callbackAnnotation is not null)
+ {
+ var context = new TestResultsCallbackContext(serviceProvider, cancellationToken);
+ await callbackAnnotation.Callback(context).ConfigureAwait(false);
+
+ resultFiles = context.ResultFiles
+ .Select(f => new TestResultFileInfo
+ {
+ FilePath = f.File.FullName,
+ Format = f.Format.ToString()
+ })
+ .ToArray();
+
+ logger.LogInformation("Collected {Count} result file(s) from test resource '{ResourceName}'", resultFiles.Length, resource.Name);
+ }
+
+ return (resource.Name, Success: true, Error: (string?)null, ResultFiles: resultFiles);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error waiting for test resource '{ResourceName}'", resource.Name);
+ return (resource.Name, Success: false, Error: ex.Message, ResultFiles: (TestResultFileInfo[]?)null);
+ }
+ });
+
+ var results = await Task.WhenAll(waitTasks).ConfigureAwait(false);
+
+ var allSuccessful = results.All(r => r.Success);
+ var message = allSuccessful
+ ? $"All {testResources.Count} test resource(s) completed successfully."
+ : $"Some test resources failed: {string.Join(", ", results.Where(r => !r.Success).Select(r => r.Name))}";
+
+ return new TestResults
+ {
+ Success = allSuccessful,
+ Message = message,
+ TestResourceResults = results.Select(r => new TestResourceResult
+ {
+ ResourceName = r.Name,
+ Success = r.Success,
+ Error = r.Error,
+ ResultFiles = r.ResultFiles
+ }).ToArray()
+ };
+ }
+
+ ///
+ /// Initiates an orderly shutdown of the AppHost.
+ ///
+ /// A cancellation token.
+ /// A task that completes immediately. The actual shutdown occurs after the RPC channel disconnects.
+ public Task StopAppHostAsync(CancellationToken cancellationToken = default)
+ {
+ _ = cancellationToken; // Unused - shutdown is intentionally not cancellable once requested
+
+ logger.LogInformation("Received request to stop AppHost. Scheduling shutdown after RPC disconnect.");
+
+ // Fire off a background task that waits for the RPC to disconnect, then stops the application
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ // Give the RPC response time to be sent back
+ await Task.Delay(500, CancellationToken.None).ConfigureAwait(false);
+
+ logger.LogInformation("Stopping AppHost application.");
+ hostApplicationLifetime.StopApplication();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error stopping AppHost");
+ }
+ }, CancellationToken.None);
+
+ return Task.CompletedTask;
+ }
}
+
diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs
index 35104a43939..69e34c66277 100644
--- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs
+++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs
@@ -123,7 +123,8 @@ await eventing.PublishAsync(
// Create a new RPC target for this connection
var rpcTarget = new AuxiliaryBackchannelRpcTarget(
serviceProvider.GetRequiredService>(),
- serviceProvider);
+ serviceProvider,
+ serviceProvider.GetRequiredService());
// Set up JSON-RPC over the client socket
using var stream = new NetworkStream(clientSocket, ownsSocket: true);
diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs
index ef5166c657b..bae09f1638f 100644
--- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs
+++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs
@@ -287,3 +287,66 @@ internal sealed class AppHostInformation
///
public int? CliProcessId { get; init; }
}
+
+///
+/// Results from running test resources.
+///
+internal sealed class TestResults
+{
+ ///
+ /// Gets or sets a value indicating whether all tests succeeded.
+ ///
+ public bool Success { get; set; }
+
+ ///
+ /// Gets or sets a message describing the test results.
+ ///
+ public required string Message { get; set; }
+
+ ///
+ /// Gets or sets the individual test resource results.
+ ///
+ public TestResourceResult[]? TestResourceResults { get; set; }
+}
+
+///
+/// Result from a single test resource.
+///
+internal sealed class TestResourceResult
+{
+ ///
+ /// Gets or sets the name of the test resource.
+ ///
+ public required string ResourceName { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the test succeeded.
+ ///
+ public bool Success { get; set; }
+
+ ///
+ /// Gets or sets an error message if the test failed.
+ ///
+ public string? Error { get; set; }
+
+ ///
+ /// Gets or sets the list of test result files produced by the resource.
+ ///
+ public TestResultFileInfo[]? ResultFiles { get; set; }
+}
+
+///
+/// Information about a test result file.
+///
+internal sealed class TestResultFileInfo
+{
+ ///
+ /// Gets or sets the full path to the test result file.
+ ///
+ public required string FilePath { get; set; }
+
+ ///
+ /// Gets or sets the format of the test results.
+ ///
+ public required string Format { get; set; }
+}
diff --git a/tests/Aspire.Cli.Tests/Commands/TestCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TestCommandTests.cs
new file mode 100644
index 00000000000..9721eac5c1c
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/Commands/TestCommandTests.cs
@@ -0,0 +1,108 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using Aspire.Cli.Tests.Utils;
+using Microsoft.Extensions.DependencyInjection;
+using RootCommand = Aspire.Cli.Commands.RootCommand;
+
+namespace Aspire.Cli.Tests.Commands;
+
+public class TestCommandTests
+{
+ private readonly ITestOutputHelper _outputHelper;
+
+ public TestCommandTests(ITestOutputHelper outputHelper)
+ {
+ _outputHelper = outputHelper;
+ }
+
+ [Fact]
+ public async Task TestCommandWithHelpArgumentReturnsZero()
+ {
+ using var workspace = TemporaryWorkspace.Create(_outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, _outputHelper, options =>
+ {
+ options.EnabledFeatures = [KnownFeatures.TestCommandEnabled];
+ });
+ var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ var invokeConfiguration = new InvocationConfiguration();
+ invokeConfiguration.Output = new TestOutputTextWriter(_outputHelper);
+
+ var result = command.Parse("test --help");
+
+ var exitCode = await result.InvokeAsync(invokeConfiguration).WaitAsync(CliTestConstants.DefaultTimeout);
+ Assert.Equal(ExitCodeConstants.Success, exitCode);
+ }
+
+ [Fact]
+ public async Task TestCommand_WhenFeatureFlagEnabled_CommandAvailable()
+ {
+ using var workspace = TemporaryWorkspace.Create(_outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, _outputHelper, options =>
+ {
+ options.EnabledFeatures = [KnownFeatures.TestCommandEnabled];
+ });
+ var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ var invokeConfiguration = new InvocationConfiguration();
+ var testOutputWriter = new TestOutputTextWriter(_outputHelper);
+ invokeConfiguration.Output = testOutputWriter;
+
+ var result = command.Parse("test --help");
+
+ var exitCode = await result.InvokeAsync(invokeConfiguration).WaitAsync(CliTestConstants.DefaultTimeout);
+
+ // Should succeed because test command is registered when feature flag is enabled
+ Assert.Equal(ExitCodeConstants.Success, exitCode);
+ }
+
+ [Fact]
+ public async Task TestCommand_WhenFeatureFlagDisabled_CommandNotAvailable()
+ {
+ using var workspace = TemporaryWorkspace.Create(_outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, _outputHelper, options =>
+ {
+ options.DisabledFeatures = [KnownFeatures.TestCommandEnabled];
+ });
+ var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ var invokeConfiguration = new InvocationConfiguration();
+ var testOutputWriter = new TestOutputTextWriter(_outputHelper);
+ invokeConfiguration.Output = testOutputWriter;
+
+ var result = command.Parse("test");
+
+ var exitCode = await result.InvokeAsync(invokeConfiguration).WaitAsync(CliTestConstants.DefaultTimeout);
+
+ // Should fail because test command is not registered when feature flag is disabled
+ Assert.NotEqual(ExitCodeConstants.Success, exitCode);
+ }
+
+ [Fact]
+ public async Task TestCommand_WhenNoProjectFileFound_ReturnsFailedToFindProject()
+ {
+ using var workspace = TemporaryWorkspace.Create(_outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, _outputHelper, options =>
+ {
+ options.EnabledFeatures = [KnownFeatures.TestCommandEnabled];
+ });
+ var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ var invokeConfiguration = new InvocationConfiguration();
+ var testOutputWriter = new TestOutputTextWriter(_outputHelper);
+ invokeConfiguration.Output = testOutputWriter;
+
+ var result = command.Parse("test");
+
+ var exitCode = await result.InvokeAsync(invokeConfiguration).WaitAsync(CliTestConstants.DefaultTimeout);
+
+ // Should fail because no project is found
+ Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode);
+ }
+}
diff --git a/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs
index 10ace237c82..bacbb97bb4b 100644
--- a/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs
+++ b/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs
@@ -154,6 +154,7 @@ private static AppHostConnection CreateAppHostConnection(string hash, string soc
{
// Create a mock JsonRpc that won't be used
var rpc = new JsonRpc(Stream.Null);
- return new AppHostConnection(hash, socketPath, rpc, mcpInfo: null, appHostInfo, isInScope);
+ var backchannel = new AuxiliaryBackchannel(rpc);
+ return new AppHostConnection(hash, socketPath, rpc, backchannel, mcpInfo: null, appHostInfo, isInScope);
}
}
diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
index 554d9b59a58..8a082bce5f6 100644
--- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
+++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
@@ -104,6 +104,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();